Compare commits

..

4 Commits

Author SHA1 Message Date
openhands
635a29a47c Revise Scenario 1 design to align with engineer's sandbox spec approach
Major changes to align with existing OpenHands infrastructure:
- Use enhanced sandbox specifications instead of parallel CustomAgentSpec system
- Implement permissions as core M1 requirement (not M4 afterthought)
- Support both pre-built image registration and secure user upload workflows
- Extend existing SandboxSpecService rather than creating separate infrastructure
- Integrate with existing conversation creation API via sandbox_spec_id parameter
- Address V0 security issues with comprehensive validation and approval workflows
- Maintain backward compatibility with existing sandbox system

This approach is more pragmatic and leverages proven OpenHands infrastructure
while addressing the engineer's specific requirements for permissions and security.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-02 19:16:00 +00:00
openhands
9f04705b30 Enhance Scenario 2 design doc with detailed V1 architecture explanation
- Add comprehensive explanation of current V1 agent instantiation and execution flow
- Detail how agents are currently created, managed, and executed in agent server containers
- Explain the distributed service model and communication patterns
- Clarify integration points and how dynamic loading would modify existing processes
- Provide better context for understanding the technical changes required

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-02 19:07:17 +00:00
openhands
d9b6901785 Add design document for Dynamic Custom Agent Package Loading (Scenario 2)
- Comprehensive design for dynamic loading of custom agent packages within existing agent server container
- Supports Git repositories, PyPI packages, and ZIP archives without requiring custom Docker images
- Maintains full compatibility with existing V1 HTTP API and infrastructure
- Includes security validation, error handling, and fallback mechanisms
- Covers 4-milestone implementation plan with detailed technical specifications

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-01 17:58:44 +00:00
openhands
39c240527c Add design document for Custom Agent Packages with Custom Runtime Images (Scenario 1)
- Comprehensive design for supporting custom agent packages that require custom system dependencies
- Extends V1 architecture to support custom Docker images with specialized tools and runtimes
- Maintains compatibility with existing HTTP API and tooling ecosystem
- Includes detailed technical design, implementation plan, and user interface specifications

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-01 17:37:35 +00:00
1389 changed files with 106651 additions and 114411 deletions

12
.github/CODEOWNERS vendored
View File

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

View File

@@ -5,113 +5,52 @@ labels: ['bug']
body:
- type: markdown
attributes:
value: |
## Thank you for reporting a bug! 🐛
**Please fill out all required fields.** Issues missing critical information (version, installation method, reproduction steps, etc.) will be delayed or closed until complete details are provided.
Clear, detailed reports help us resolve issues faster.
value: Thank you for taking the time to fill out this bug report. Please provide as much information as possible
to help us understand and address the issue effectively.
- type: checkboxes
attributes:
label: Is there an existing issue for the same bug?
description: Please search existing issues before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
label: Is there an existing issue for the same bug? (If one exists, thumbs up or comment on the issue instead).
description: Please check if an issue already exists for the bug you encountered.
options:
- label: I have searched existing issues and this is not a duplicate.
- label: I have checked the existing issues.
required: true
- type: textarea
id: bug-description
attributes:
label: Bug Description
description: Clearly describe what went wrong. Be specific and concise.
placeholder: Example - "When I run a Python task, OpenHands crashes after 30 seconds with a connection timeout error."
label: Describe the bug and reproduction steps
description: Provide a description of the issue along with any reproduction steps.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Example - "OpenHands should execute the Python script and return results."
validations:
required: false
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Example - "Connection timed out after 30 seconds, task failed with error code 500."
validations:
required: false
- type: textarea
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Provide clear, step-by-step instructions to reproduce the bug.
placeholder: |
1. Install OpenHands using Docker
2. Configure with Claude 3.5 Sonnet
3. Run command: `openhands run "write a python script"`
4. Wait 30 seconds
5. Error appears
validations:
required: false
- type: dropdown
id: installation
attributes:
label: OpenHands Installation Method
label: OpenHands Installation
description: How are you running OpenHands?
options:
- CLI (uv tool install)
- CLI (executable binary)
- CLI (Docker)
- Local GUI (Docker web interface)
- OpenHands Cloud (app.all-hands.dev)
- SDK (Python library)
- Docker command in README
- GitHub resolver
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0
validations:
required: false
- type: input
id: installation-other
attributes:
label: If you selected "Other", please specify
description: Describe your installation method
placeholder: ex. Custom Kubernetes deployment, pip install from source, etc.
- type: input
id: openhands-version
attributes:
label: OpenHands Version
description: What version are you using? Find this in settings or by running `openhands --version`
placeholder: ex. 0.9.8, main, commit hash, etc.
validations:
required: false
- type: checkboxes
id: version-confirmation
attributes:
label: Version Confirmation
description: Bugs on older versions may already be fixed. Please upgrade before submitting.
options:
- label: "I have confirmed this bug exists on the LATEST version of OpenHands"
required: false
description: What version of OpenHands are you using?
placeholder: ex. 0.9.8, main, etc.
- type: input
id: model-name
attributes:
label: Model Name
description: Which LLM model are you using?
placeholder: ex. gpt-4o, claude-3-5-sonnet-20241022, openrouter/deepseek-r1, etc.
validations:
required: false
description: What model are you using?
placeholder: ex. gpt-4o, claude-3-5-sonnet, openrouter/deepseek-r1, etc.
- type: dropdown
id: os
@@ -121,46 +60,12 @@ body:
- MacOS
- Linux
- WSL on Windows
- Windows (Docker Desktop)
- Other
validations:
required: false
- type: input
id: browser
attributes:
label: Browser (if using web UI)
description: |
If applicable, which browser and version?
placeholder: ex. Chrome 131, Firefox 133, Safari 17.2
- type: textarea
id: logs
attributes:
label: Logs and Error Messages
description: |
**Paste relevant logs, error messages, or stack traces.** Use code blocks (```) for formatting.
LLM logs are in `logs/llm/default/`. Include timestamps if errors occurred at a specific time.
placeholder: |
```
Paste error logs here
```
- type: textarea
id: additional-context
attributes:
label: Screenshots and Additional Context
description: |
Add screenshots, videos, runtime environment, or other context that helps explain the issue.
💡 **Share conversation history:** In the OpenHands chat UI, click the 👎 or 👍 button (above the message input) to generate a shareable link to your conversation.
placeholder: Drag and drop screenshots here, paste links, or add additional context.
- type: markdown
attributes:
value: |
---
**Note:** Issues with incomplete information may be closed or deprioritized. Maintainers and community members have limited bandwidth and prioritize well-documented bugs that are easier to reproduce and fix. Thank you for your understanding!
label: Logs, Errors, Screenshots, and Additional Context
description: Please provide any additional information you think might help. If you want to share the chat history
you can click the thumbs-down (👎) button above the input field and you will get a shareable link
(you can also click thumbs up when things are going well of course!). LLM logs will be stored in the
`logs/llm/default` folder. Please add any additional context about the problem here.

View File

@@ -0,0 +1,17 @@
---
name: Feature Request or Enhancement
about: Suggest an idea for an OpenHands feature or enhancement
title: ''
labels: 'enhancement'
assignees: ''
---
**What problem or use case are you trying to solve?**
**Describe the UX or technical implementation you have in mind**
**Additional context**
### If you find this feature request or enhancement useful, make sure to add a 👍 to the issue

View File

@@ -1,105 +0,0 @@
name: Feature Request or Enhancement
description: Suggest a new feature or improvement for OpenHands
title: '[Feature]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
## Thank you for suggesting a feature! 💡
**Please provide detailed information.** Vague or low-effort requests may be closed. Well-documented feature requests with strong community support are more likely to be added to the roadmap.
- type: checkboxes
attributes:
label: Is there an existing feature request for this?
description: Please search existing issues and feature requests before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
options:
- label: I have searched existing issues and feature requests, and this is not a duplicate.
required: true
- type: textarea
id: problem-statement
attributes:
label: Problem or Use Case
description: What problem are you trying to solve? What use case would this feature enable?
placeholder: |
Example - "As a developer working on large codebases, I need to search across multiple files simultaneously. Currently, I have to search file-by-file which is time-consuming and inefficient."
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Describe your ideal solution. What should this feature do? How should it work?
placeholder: |
Example - "Add a global search feature that allows searching across all files in the workspace. Results should show file name, line number, and context around matches. Include regex support and filtering options."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds? What are their limitations?
placeholder: Example - "I tried using grep in the terminal, but it's not integrated with the UI and doesn't provide click-to-navigate functionality."
- type: dropdown
id: priority
attributes:
label: Priority / Severity
description: How important is this feature to your workflow?
options:
- "Critical - Blocking my work, no workaround available"
- "High - Significant impact on productivity"
- "Medium - Would improve experience"
- "Low - Nice to have"
default: 2
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Estimated Scope
description: To the best of your knowledge, how complex do you think this feature would be to implement?
options:
- "Small - UI tweak, config option, or minor change"
- "Medium - New feature with moderate complexity"
- "Large - Significant feature requiring architecture changes"
- "Unknown - Not sure about the technical complexity"
default: 3
- type: dropdown
id: feature-area
attributes:
label: Feature Area
description: Which part of OpenHands does this feature relate to? If you select "Other", please specify the area in the Additional Context section below.
options:
- "Agent / AI behavior"
- "User Interface / UX"
- "CLI / Command-line interface"
- "File system / Workspace management"
- "Configuration / Settings"
- "Integrations (GitHub, GitLab, etc.)"
- "Performance / Optimization"
- "Documentation"
- "Other"
validations:
required: true
- type: textarea
id: technical-details
attributes:
label: Technical Implementation Ideas (Optional)
description: If you have technical expertise, share implementation ideas, API suggestions, or relevant technical details.
placeholder: |
Example - "Could use ripgrep library for fast search. Expose results via /api/search endpoint. Frontend can use virtualized list for rendering large result sets."
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, screenshots, mockups, or examples that help illustrate this feature request.
placeholder: Drag and drop screenshots, mockups, or links here.

View File

@@ -1,16 +1,10 @@
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
## Summary of PR
<!-- Summarize what the PR does -->
## Demo Screenshots/Videos
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
## Change Type
<!-- Choose the types that apply to your PR -->
<!-- Choose the types that apply to your PR and remove the rest. -->
- [ ] Bug fix
- [ ] New feature

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"

View File

@@ -27,7 +27,7 @@ jobs:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'poetry'
@@ -38,7 +38,7 @@ jobs:
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
@@ -192,7 +192,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/test-results/
@@ -200,7 +200,7 @@ jobs:
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: openhands-logs
path: |

View File

@@ -43,7 +43,7 @@ jobs:
⚠️ This PR contains **migrations**
- name: Comment warning on PR
uses: peter-evans/create-or-update-comment@v5
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}

View File

@@ -0,0 +1,29 @@
# Feature branch preview for enterprise code
name: Enterprise Preview
# Run on PRs labeled
on:
pull_request:
types: [labeled]
# Match ghcr-build.yml, but don't interrupt it.
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: false
jobs:
# This must happen for the PR Docker workflow when the label is present,
# and also if it's added after the fact. Thus, it exists in both places.
enterprise-preview:
name: Enterprise preview
if: github.event.label.name == 'deploy'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
# This should match the version in ghcr-build.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches

View File

@@ -1,47 +0,0 @@
# Workflow that runs frontend e2e tests with Playwright
name: Run Frontend E2E Tests
on:
push:
branches:
- main
pull_request:
paths:
- "frontend/**"
- ".github/workflows/fe-e2e-tests.yml"
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Install Playwright browsers
working-directory: ./frontend
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 30

View File

@@ -9,7 +9,6 @@ on:
push:
branches:
- main
- "saas-rel-*"
tags:
- "*"
pull_request:
@@ -40,7 +39,8 @@ jobs:
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
else
json=$(jq -n -c '[
@@ -64,7 +64,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
uses: docker/setup-qemu-action@v3.6.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -102,7 +102,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
uses: docker/setup-qemu-action@v3.6.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -149,9 +149,6 @@ jobs:
push: true
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
# Caching directives to boost performance
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
@@ -164,7 +161,7 @@ jobs:
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
@@ -240,15 +237,166 @@ jobs:
# Add build attestations for better security
sbom: true
enterprise-preview:
name: Enterprise preview
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_enterprise]
steps:
# This should match the version in enterprise-preview.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
# Forked repos can't push to GHCR, so we need to rebuild using cache
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flakey tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
# Setting RUN_AS_OPENHANDS to false means use root.
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
# Forked repos can't push to GHCR, so we need to rebuild using cache
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
TEST_RUNTIME=docker \
SANDBOX_USER_ID=$(id -u) \
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
# Due to this bug: https://github.com/actions/runner/issues/2566, we want to create a job that runs when the
# prerequisites have been cancelled or failed so merging is disallowed, otherwise Github considers "skipped" as "success"
runtime_tests_check_success:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
runtime_tests_check_fail:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: Some tests failed
run: |
echo "Some runtime tests failed or were cancelled"
exit 1
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'

View File

@@ -89,7 +89,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Upgrade pip
@@ -118,7 +118,7 @@ jobs:
contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
@@ -269,7 +269,7 @@ jobs:
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: always() # Upload even if the previous steps fail
with:
name: resolver-output

View File

@@ -1,48 +0,0 @@
---
name: PR Review by OpenHands
on:
# TEMPORARY MITIGATION (Clinejection hardening)
#
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
# workflow is fully hardened for untrusted execution.
pull_request:
types: [opened, ready_for_review, labeled, review_requested]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-review:
# Note: fork PRs will not have access to repository secrets under `pull_request`.
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
(
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
github.event.action == 'ready_for_review' ||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
(
github.event.action == 'review_requested' &&
(
github.event.requested_reviewer.login == 'openhands-agent' ||
github.event.requested_reviewer.login == 'all-hands-bot'
)
)
)
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: ubuntu-24.04
steps:
- name: Run PR Review
uses: OpenHands/extensions/plugins/pr-review@main
with:
llm-model: litellm_proxy/claude-sonnet-4-5-20250929
llm-base-url: https://llm-proxy.app.all-hands.dev
review-style: roasted
llm-api-key: ${{ secrets.LLM_API_KEY }}
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}

View File

@@ -1,85 +0,0 @@
---
name: PR Review Evaluation
# This workflow evaluates how well PR review comments were addressed.
# It runs when a PR is closed to assess review effectiveness.
#
# Security note: pull_request_target is safe here because:
# 1. Only triggers on PR close (not on code changes)
# 2. Does not checkout PR code - only downloads artifacts from trusted workflow runs
# 3. Runs evaluation scripts from the extensions repo, not from the PR
on:
pull_request_target:
types: [closed]
permissions:
contents: read
pull-requests: read
jobs:
evaluate:
runs-on: ubuntu-24.04
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_NAME: ${{ github.repository }}
PR_MERGED: ${{ github.event.pull_request.merged }}
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v6
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml
name: pr-review-trace-${{ github.event.pull_request.number }}
path: trace-info
search_artifacts: true
if_no_artifact_found: warn
- name: Check if trace file exists
id: check-trace
run: |
if [ -f "trace-info/laminar_trace_info.json" ]; then
echo "trace_exists=true" >> $GITHUB_OUTPUT
echo "Found trace file for PR #$PR_NUMBER"
else
echo "trace_exists=false" >> $GITHUB_OUTPUT
echo "No trace file found for PR #$PR_NUMBER - skipping evaluation"
fi
# Always checkout main branch for security - cannot test script changes in PRs
- name: Checkout extensions repository
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/checkout@v5
with:
repository: OpenHands/extensions
path: extensions
- name: Set up Python
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install dependencies
if: steps.check-trace.outputs.trace_exists == 'true'
run: pip install lmnr
- name: Run evaluation
if: steps.check-trace.outputs.trace_exists == 'true'
env:
# Script expects LMNR_PROJECT_API_KEY; org secret is named LMNR_SKILLS_API_KEY
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python extensions/plugins/pr-review/scripts/evaluate_review.py \
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v5
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}
path: '*.log'
retention-days: 30

View File

@@ -63,7 +63,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: coverage-openhands
path: |
@@ -95,7 +95,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
@@ -113,7 +113,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v5
id: download
with:
pattern: coverage-*

View File

@@ -9,7 +9,6 @@ on:
jobs:
stale:
runs-on: blacksmith-4vcpu-ubuntu-2204
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v9
with:

View File

@@ -0,0 +1,156 @@
# 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@v5
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@v4
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@v4
with:
name: vscode-extension
path: ./
- name: Create Release
uses: ncipollo/release-action@v1.16.0
with:
artifacts: "*.vsix"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
allowUpdates: true

View File

@@ -45,7 +45,7 @@ jobs:
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
"Feel free to join our developer community on [Slack](https://openhands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"🙌 Happy hacking! 🙌\n\n" +
"<!-- auto-comment:good-first-issue -->"
});

View File

@@ -63,7 +63,7 @@ Frontend:
- We use TanStack Query (fka React Query) for data fetching and cache management
- Data Access Layer: API client methods are located in `frontend/src/api` and should never be called directly from UI components - they must always be wrapped with TanStack Query
- Custom hooks are located in `frontend/src/hooks/query/` and `frontend/src/hooks/mutation/`
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationMicroagents`)
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
@@ -150,9 +150,9 @@ Each integration follows a consistent pattern with service classes, storage mode
**Important Notes:**
- Enterprise code is licensed under Polyform Free Trial License (30-day limit)
- The enterprise server extends the OpenHands server through dynamic imports
- The enterprise server extends the OSS server through dynamic imports
- Database changes require careful migration planning in `enterprise/migrations/`
- Always test changes in both OpenHands and enterprise contexts
- Always test changes in both OSS and enterprise contexts
- Use the enterprise-specific Makefile commands for development
**Enterprise Testing Best Practices:**
@@ -166,7 +166,7 @@ Each integration follows a consistent pattern with service classes, storage mode
**Import Patterns:**
- Use relative imports without `enterprise.` prefix in enterprise code
- Example: `from storage.database import session_maker` not `from enterprise.storage.database import session_maker`
- This ensures code works in both OpenHands and enterprise contexts
- This ensures code works in both OSS and enterprise contexts
**Test Structure:**
- Place tests in `enterprise/tests/unit/` following the same structure as the source code

View File

@@ -13,6 +13,7 @@ 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
@@ -20,12 +21,17 @@ 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
@@ -86,6 +92,51 @@ 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

@@ -61,7 +61,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@openhands.dev.
contact@all-hands.dev.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
@@ -115,9 +115,7 @@ community.
### Slack Etiquettes
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all
community members. By following these best practices, we ensure effective communication and collaboration while
minimizing disruptions. Lets work together to build a supportive and welcoming community!
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Lets work together to build a supportive and welcoming community!
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
- Use threads for specific discussions to keep channels organized and easier to follow.
@@ -127,10 +125,7 @@ minimizing disruptions. Lets work together to build a supportive and welcomin
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
- Always adhere to [our standards](https://github.com/OpenHands/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged.
For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned.
For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to
alert you only when “LLMs” appears in messages.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to alert you only when “LLMs” appears in messages.
## Attribution

View File

@@ -1,44 +1,36 @@
# The OpenHands Community
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered
world.
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered world.
## Mission
Its very clear that AI is changing software development. We want the developer community to drive that change
organically, through open source.
Its very clear that AI is changing software development. We want the developer community to drive that change organically, through open source.
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that
empower developers to create new experiences, tailored to your own habits, needs, and imagination.
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that empower developers to create new experiences, tailored to your own habits, needs, and imagination.
## Ethos
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to
embody these values, we want to establish them as norms.
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to embody these values, we want to establish them as norms.
### High Openness
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us
build. You dont have to be pro-AI to help us learn.
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us build. You dont have to be pro-AI to help us learn.
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the
fruits of our work, but the whole process of growing it.
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the fruits of our work, but the whole process of growing it.
We welcome thoughtful criticism, whether its a comment on a PR or feedback on the community as a whole.
### High Agency
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing
feedback, or just asking a question, dont hold back!
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing feedback, or just asking a question, dont hold back!
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly
and love building new things.
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly and love building new things.
Coding, development practices, and communities are changing rapidly. We wont hesitate to change direction and make big bets.
## Relationship to All Hands
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.openhands.dev/).
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.all-hands.dev/).
All Hands was founded by three of the first major contributors to OpenHands:
@@ -46,13 +38,8 @@ All Hands was founded by three of the first major contributors to OpenHands:
- Graham Neubig, a CMU Professor who rallied the academic community around OpenHands
- Robert Brennan, a software engineer who architected the user-facing features of OpenHands
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and
researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility
to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we
promise to navigate that conflict thoughtfully and transparently.
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we promise to navigate that conflict thoughtfully and transparently.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now,
the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the
“benevolent” part, please: fork us.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now, the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the “benevolent” part, please: fork us.

View File

@@ -6,40 +6,35 @@ Thanks for your interest in contributing to OpenHands! We welcome and appreciate
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [evaluation](./evaluation/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
## Setting up Your Development Environment
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
you how to set up a development workflow.
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
## How Can I Contribute?
There are many ways that you can contribute:
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
## What Can I Build?
Here are a few ways you can help improve the codebase.
#### UI/UX
We're always looking to improve the look and feel of the application. If you've got a small fix
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 #dev-ui-ux channel in our Slack
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
to gather consensus from our design team first.
#### Improving the agent
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
@@ -51,12 +46,10 @@ We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. Y
channel in Slack to learn more.
#### Adding a new agent
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
to help expand the capabilities of OpenHands.
#### Adding a new runtime
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
to do this by default. But there are other ways of creating a sandbox for the agent.
@@ -64,11 +57,8 @@ If you work for a company that provides a cloud-based runtime, you could help us
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
#### Testing
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
quality of the project.
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
## Sending Pull Requests to OpenHands
@@ -76,8 +66,7 @@ You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
### Pull Request title
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
- `feat`: A new feature
- `fix`: A bug fix
@@ -98,7 +87,6 @@ For example, a PR title could be:
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request description
- If your PR is small (such as a typo fix), you can go brief.
- If it contains a lot of changes, it's better to write more details.
@@ -109,9 +97,7 @@ please include a short message that we can add to our changelog.
### Opening Issues
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
the community has interest/effort for.
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
@@ -122,13 +108,11 @@ We're generally happy to consider all pull requests with the evaluation process
#### For Small Improvements
Small improvements with few downsides are typically reviewed and approved quickly.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
before getting a review.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
#### For Core Agent Changes
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
evaluated based on three key metrics:
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
1. **Accuracy**
2. **Efficiency**

View File

@@ -2,13 +2,11 @@
## Contributors
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have
helped make OpenHands possible. We greatly appreciate your dedication and hard work.
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
## Open Source Projects
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the
open source community:
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the open source community:
#### [SWE Agent](https://github.com/princeton-nlp/swe-agent)
- License: MIT License
@@ -22,8 +20,8 @@ open source community:
- License: Apache License 2.0
- Description: Adapted in implementing the browsing agent
### Reference Implementations for Evaluation Benchmarks
### Reference Implementations for Evaluation Benchmarks
OpenHands integrates code of the reference implementations for the following agent evaluation benchmarks:
#### [HumanEval](https://github.com/openai/human-eval)
@@ -54,44 +52,28 @@ OpenHands integrates code of the reference implementations for the following age
#### [ProntoQA](https://github.com/asaparov/prontoqa)
- License: Apache License 2.0
## Open Source licenses
### MIT License
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:
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 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.
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.
### BSD 3-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
following conditions are met:
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
disclaimer.
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided with the distribution.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
products derived from this software without specific prior written permission.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
### Apache License 2.0
@@ -286,6 +268,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Copyright [yyyy] [name of copyright owner]
### Non-Open Source Reference Implementations:
#### [MultiPL-E](https://github.com/nuprl/MultiPL-E)

View File

@@ -76,7 +76,7 @@ variables in your terminal. The final configurations are set from highest to low
Environment variables > config.toml variables > default variables
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
See [our documentation](https://docs.all-hands.dev/usage/llms) for recommended models.
### 4. Running the application
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
## Develop inside Docker container
@@ -195,12 +195,12 @@ Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [DOC_STYLE_GUIDE.md](https://github.com/All-Hands-AI/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -3,14 +3,14 @@ These are the procedures and guidelines on how issues are triaged in this repo b
## General
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
* Issues may be tagged with what it relates to (**llm**, **app tab**, **UI/UX**, etc.).
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
## Severity
* **High**: High visibility issues or affecting many users.
* **Critical**: Affecting all users or potential security issues.
## Difficulty
* Issues good for newcomers may be tagged with **good first issue**.
* Issues with low implementation difficulty may be tagged with **good first issue**.
## Not Enough Information
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
@@ -22,6 +22,6 @@ the issue may be closed as **not planned** (Usually after a week).
* Issues may be broken down into multiple issues if required.
## Stale and Auto Closures
* In order to keep a maintainable backlog, issues that have no activity within 40 days are automatically marked as **Stale**.
* If issues marked as **Stale** continue to have no activity for 10 more days, they will automatically be closed as not planned.
* In order to keep a maintainable backlog, issues that have no activity within 30 days are automatically marked as **Stale**.
* If issues marked as **Stale** continue to have no activity for 7 more days, they will automatically be closed as not planned.
* Issues may be reopened by maintainers if deemed important.

View File

@@ -8,7 +8,7 @@
<div align="center">
<a href="https://github.com/OpenHands/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/badge/LICENSE-MIT-20B2AA?style=for-the-badge" alt="MIT License"></a>
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-77.6-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-72.8-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
<br/>
<a href="https://docs.openhands.dev/sdk"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
<a href="https://arxiv.org/abs/2511.03690"><img src="https://img.shields.io/badge/Paper-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Tech Report"></a>
@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
### OpenHands Cloud
This is a deployment of OpenHands GUI, running on hosted infrastructure.
You can try it for free using the Minimax model by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
You can try it with a free $10 credit by [signing in with your GitHub account](https://app.all-hands.dev).
OpenHands Cloud comes with source-available features and integrations:
- Integrations with Slack, Jira, and Linear

113
build_vscode.py Normal file
View File

@@ -0,0 +1,113 @@
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

@@ -440,6 +440,12 @@ type = "noop"
#temperature = 0.1
#max_input_tokens = 1024
#################################### Eval ####################################
# Configuration for the evaluation, please refer to the specific evaluation
# plugin for the available options
##############################################################################
########################### Kubernetes #######################################
# Kubernetes configuration when using the Kubernetes runtime
##############################################################################

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.2-trixie-slim AS frontend-builder
FROM node:24.8-trixie-slim AS frontend-builder
WORKDIR /app

View File

@@ -12,8 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/runtime}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.2-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -10,15 +10,6 @@ repos:
args: ["--allow-multiple-documents"]
- id: debug-statements
- repo: local
hooks:
- id: warn-appmode-oss
name: "Warn on AppMode.OSS in backend (use AppMode.OPENHANDS)"
language: system
entry: bash -lc 'if rg -n "\\bAppMode\\.OSS\\b" openhands tests/unit; then echo "Found AppMode.OSS usage. Prefer AppMode.OPENHANDS."; exit 1; fi'
pass_filenames: false
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.5.1
hooks:

View File

@@ -0,0 +1,836 @@
# Custom Agent Packages with Custom Runtime Images (Scenario 1)
## 1. Introduction
### 1.1 Problem Statement
OpenHands currently supports agent customization through the software-agent-sdk, but users who need custom system dependencies, specialized tools, or non-Python runtime environments cannot easily deploy their agents. The current V1 architecture uses a fixed agent server image (`ghcr.io/openhands/agent-server:5f62cee-python`) that may not contain the required dependencies for specialized agents.
Users building agents that require:
- Custom system packages (e.g., specialized compilers, databases, ML frameworks)
- Non-Python tools and runtimes (e.g., Node.js, Go, Rust toolchains)
- Custom Docker base images with specific OS configurations
- Proprietary or licensed software installations
Currently have no supported path to deploy their agents to OpenHands Enterprise.
### 1.2 Proposed Solution
We propose extending the existing **Sandbox Specification System** to support custom agent runtime images with proper permissions and security controls. This approach builds directly on OpenHands' current sandbox infrastructure rather than creating parallel systems.
Users will be able to:
1. Create custom Docker images containing their agent code and dependencies
2. Register these images as enhanced sandbox specifications with rich metadata
3. Deploy conversations using their custom sandbox specs (with proper permissions)
4. Maintain full compatibility with existing sandbox management and API infrastructure
The solution extends the current `SandboxSpecService` with:
- **Permission-based access control** to limit custom specs to authorized users
- **Enhanced sandbox specifications** that include agent-specific metadata and requirements
- **Secure image management** with validation and approval workflows
- **Integrated deployment** through existing conversation creation APIs
**Trade-offs**: This approach requires users to build and maintain Docker images, increasing complexity compared to simple Python package deployment. However, it provides the necessary isolation and dependency management for complex agent requirements while leveraging proven sandbox infrastructure.
## 2. User Interface
### 2.1 Custom Agent Image Creation
Users create a custom agent image by extending the base agent server image:
```dockerfile
# Dockerfile for custom agent
FROM ghcr.io/openhands/agent-server:5f62cee-python
# Install custom system dependencies
RUN apt-get update && apt-get install -y \
nodejs \
npm \
golang-go \
&& rm -rf /var/lib/apt/lists/*
# Install custom Python packages
COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt
# Copy custom agent code
COPY my_custom_agent/ /app/my_custom_agent/
COPY agent_config.json /app/config/
# Set custom agent as default
ENV CUSTOM_AGENT_MODULE=my_custom_agent
ENV CUSTOM_AGENT_CLASS=MySpecializedAgent
```
### 2.2 Enhanced Sandbox Spec Registration
Users register their custom agent image as an enhanced sandbox specification:
```yaml
# enhanced-sandbox-spec.yaml
apiVersion: openhands.ai/v1
kind: SandboxSpec
metadata:
name: specialized-ml-agent
version: "1.0.0"
owner: user@company.com
permissions:
users: ["user@company.com", "team-lead@company.com"]
groups: ["ml-team", "data-science"]
spec:
image: "myregistry/specialized-ml-agent:v1.0.0"
description: "ML agent with TensorFlow and custom data processing tools"
# Agent-specific metadata
agent:
capabilities:
- machine_learning
- data_analysis
- custom_visualization
type: "custom"
module: "agents.specialized_ml_agent"
class: "SpecializedMLAgent"
requirements:
memory: "4Gi"
cpu: "2"
environment:
TENSORFLOW_VERSION: "2.15.0"
CUSTOM_MODEL_PATH: "/app/models"
# Agent server configuration
CUSTOM_AGENT_MODULE: "agents.specialized_ml_agent"
CUSTOM_AGENT_CLASS: "SpecializedMLAgent"
ports:
- name: agent-server
port: 8000
- name: tensorboard
port: 6006
```
### 2.3 Conversation Creation with Custom Sandbox Spec
Users create conversations using their custom sandbox specs through the existing API:
```bash
# Create conversation with custom sandbox spec
curl -X POST "https://api.openhands.ai/api/conversations" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sandbox_spec_id": "specialized-ml-agent:v1.0.0",
"initial_message": "Analyze this dataset and create a predictive model",
"workspace": {
"type": "local",
"working_dir": "/workspace/ml-project"
}
}'
```
### 2.4 Image Management Workflows
#### 2.4.1 Pre-built Image Approach
For organizations that want to manage custom agent images centrally:
```bash
# Admin registers pre-built image as sandbox spec
curl -X POST "https://api.openhands.ai/api/sandbox-specs" \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "company-ml-agent",
"version": "1.0.0",
"image": "company-registry/ml-agent:v1.0.0",
"permissions": {
"groups": ["ml-team", "data-science"]
},
"agent": {
"type": "custom",
"capabilities": ["machine_learning", "data_analysis"]
}
}'
```
#### 2.4.2 User Upload Approach
For users who want to upload their own custom images:
```bash
# User uploads custom image (with security validation)
curl -X POST "https://api.openhands.ai/api/sandbox-specs/upload" \
-H "Authorization: Bearer $API_KEY" \
-F "dockerfile=@Dockerfile" \
-F "context=@agent-context.tar.gz" \
-F "spec=@sandbox-spec.yaml"
```
## 3. Other Context
### 3.1 Current Sandbox Specification System
OpenHands V1 uses a sandbox specification system to manage container deployments:
- **Single Default Spec**: Currently only one sandbox spec exists, shared by all users
- **SandboxSpecService**: Manages sandbox specifications and container creation
- **SandboxSpecInfo**: Contains image, environment, and resource configuration
- **No Permissions**: Current system lacks user-based access control
The existing system provides the foundation but needs enhancement for custom agents:
- **Permission Layer**: Required to control access to custom specs
- **Rich Metadata**: Need agent-specific information beyond basic container config
- **Image Management**: Need secure workflows for custom image registration
### 3.2 Enhanced Sandbox Specification Architecture
Our proposal extends the existing system with:
#### 3.2.1 Permission-Based Access Control
- **User Permissions**: Individual user access to specific sandbox specs
- **Group Permissions**: Team-based access control for organizational specs
- **Owner Management**: Spec ownership and delegation capabilities
- **Admin Override**: Administrative access for spec management
#### 3.2.2 Agent-Specific Metadata
- **Agent Configuration**: Module, class, and capability information
- **Resource Requirements**: Memory, CPU, and storage specifications
- **Environment Variables**: Agent-specific configuration and secrets
- **Port Mappings**: Additional ports for agent services (e.g., TensorBoard)
#### 3.2.3 Image Management Integration
- **Registry Support**: Integration with Docker registries for image storage
- **Security Validation**: Image scanning and approval workflows
- **Version Management**: Support for multiple versions of custom specs
- **Build Integration**: Optional image building from Dockerfile uploads
### 3.3 Existing Container Orchestration Integration
The enhanced system leverages existing OpenHands infrastructure:
- **Sandbox Service**: Extended to support permission checks and enhanced specs
- **Container Management**: Same lifecycle management with additional metadata
- **Network Isolation**: Maintains existing security boundaries
- **Resource Enforcement**: Enhanced with custom resource requirements
- **Health Monitoring**: Extended to track custom agent-specific metrics
## 4. Technical Design
### 4.1 Enhanced Sandbox Specification Model
#### 4.1.1 Extended SandboxSpecInfo Structure
The existing `SandboxSpecInfo` model is enhanced to support custom agents:
```python
# openhands/app_server/sandbox/sandbox_spec_models.py (enhanced)
from pydantic import BaseModel, Field
from typing import Dict, List, Optional
class AgentMetadata(BaseModel):
"""Agent-specific metadata for custom agents."""
type: str = Field(default="default", description="Agent type (default|custom)")
capabilities: List[str] = Field(default_factory=list, description="Agent capabilities")
module: Optional[str] = Field(description="Python module containing agent class")
class_name: Optional[str] = Field(description="Agent class name")
class PermissionSpec(BaseModel):
"""Permission specification for sandbox spec access."""
users: List[str] = Field(default_factory=list, description="Authorized user emails")
groups: List[str] = Field(default_factory=list, description="Authorized group names")
owner: Optional[str] = Field(description="Spec owner")
class EnhancedSandboxSpecInfo(BaseModel):
"""Enhanced sandbox specification with agent metadata and permissions."""
# Existing fields from SandboxSpecInfo
id: str = Field(description="Docker image identifier")
command: List[str] = Field(default_factory=lambda: ['--port', '8000'])
initial_env: Dict[str, str] = Field(default_factory=dict)
working_dir: str = Field(default="/workspace/project")
# Enhanced fields
name: str = Field(description="Human-readable spec name")
version: str = Field(description="Spec version")
description: Optional[str] = Field(description="Spec description")
# Agent-specific metadata
agent: AgentMetadata = Field(default_factory=AgentMetadata)
# Permission and access control
permissions: PermissionSpec = Field(default_factory=PermissionSpec)
# Resource requirements
memory_limit: Optional[str] = Field(description="Memory limit (e.g., '4Gi')")
cpu_limit: Optional[str] = Field(description="CPU limit (e.g., '2')")
# Additional ports for custom services
ports: List[Dict[str, any]] = Field(
default_factory=lambda: [{"name": "agent-server", "port": 8000}]
)
```
#### 4.1.2 Custom Agent Image Structure
Custom agent images extend the base agent server with this structure:
```
/app/
├── config/
│ ├── agent_config.json # Agent configuration
│ └── tool_registry.json # Custom tool definitions (optional)
├── agents/
│ └── custom_agent.py # Agent implementation
├── tools/ # Custom tools (optional)
│ ├── __init__.py
│ └── custom_tools.py
└── startup/
└── init_agent.py # Agent initialization script
```
### 4.2 Agent Implementation Interface
#### 4.2.1 Custom Agent Base Class
```python
# agents/custom_agent.py
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.llm import LLM
from openhands.sdk.tool import Tool
from typing import List, Dict, Any
class SpecializedMLAgent(AgentBase):
"""Custom ML agent with TensorFlow capabilities."""
def __init__(
self,
llm: LLM,
tools: List[Tool],
config: Dict[str, Any] = None
):
super().__init__(llm=llm, tools=tools)
self.config = config or {}
self.model_cache = self.config.get('MODEL_CACHE_DIR', '/app/models')
async def initialize(self) -> None:
"""Initialize custom agent resources."""
# Load pre-trained models
await self._load_models()
# Initialize custom tools
await self._setup_custom_tools()
async def _load_models(self) -> None:
"""Load TensorFlow models from cache."""
import tensorflow as tf
# Custom model loading logic
pass
async def _setup_custom_tools(self) -> None:
"""Initialize custom tools with agent context."""
# Custom tool setup logic
pass
```
#### 4.2.2 Custom Tool Implementation
```python
# tools/custom_tools.py
from openhands.sdk.tool import Tool, ToolExecutor, register_tool
from openhands.sdk import Action, Observation
from pydantic import Field
import tensorflow as tf
class TensorFlowAnalysisAction(Action):
dataset_path: str = Field(description="Path to dataset file")
model_type: str = Field(description="Type of ML model to create")
target_column: str = Field(description="Target column for prediction")
class TensorFlowAnalysisObservation(Observation):
model_accuracy: float = Field(description="Model accuracy score")
feature_importance: Dict[str, float] = Field(description="Feature importance scores")
model_path: str = Field(description="Path to saved model")
class TensorFlowToolExecutor(ToolExecutor[TensorFlowAnalysisAction, TensorFlowAnalysisObservation]):
def __call__(self, action: TensorFlowAnalysisAction, conversation=None) -> TensorFlowAnalysisObservation:
# Custom TensorFlow analysis logic
model = self._create_model(action.dataset_path, action.model_type, action.target_column)
accuracy = self._evaluate_model(model)
importance = self._get_feature_importance(model)
model_path = self._save_model(model)
return TensorFlowAnalysisObservation(
model_accuracy=accuracy,
feature_importance=importance,
model_path=model_path
)
# Register the custom tool
register_tool(
Tool(
name="TensorFlowTool",
executor=TensorFlowToolExecutor(),
definition=ToolDefinition(
name="tensorflow_analysis",
description="Perform machine learning analysis using TensorFlow",
parameters=TensorFlowAnalysisAction.model_json_schema()
)
)
)
```
### 4.3 Runtime Integration
#### 4.3.1 Custom Agent Loader
```python
# startup/init_agent.py
import json
import importlib
from pathlib import Path
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.llm import LLM
from openhands.sdk.tool import Tool, resolve_tool
class CustomAgentLoader:
"""Loads custom agents from configuration."""
def __init__(self, config_path: str = "/app/config/agent_config.json"):
self.config_path = Path(config_path)
self.config = self._load_config()
def _load_config(self) -> dict:
"""Load agent configuration from JSON file."""
with open(self.config_path) as f:
return json.load(f)
def create_agent(self, llm: LLM) -> AgentBase:
"""Create custom agent instance."""
agent_config = self.config["agent"]
# Import custom agent class
module = importlib.import_module(agent_config["module"])
agent_class = getattr(module, agent_config["class"])
# Load custom tools
tools = self._load_tools()
# Create agent instance
agent = agent_class(
llm=llm,
tools=tools,
config=self.config.get("environment", {})
)
return agent
def _load_tools(self) -> List[Tool]:
"""Load and resolve custom tools."""
tools = []
for tool_config in self.config.get("tools", []):
if "module" in tool_config:
# Import custom tool module to register it
importlib.import_module(tool_config["module"])
tool = resolve_tool(tool_config["name"])
tools.append(tool)
return tools
```
#### 4.3.2 Agent Server Startup Integration
```python
# Modified agent server startup in software-agent-sdk
import os
from openhands.agent_server.api import app
from openhands.agent_server.conversation_service import ConversationService
from startup.init_agent import CustomAgentLoader
@app.on_event("startup")
async def startup_event():
"""Initialize custom agent during server startup."""
# Check for custom agent configuration
custom_agent_module = os.getenv('CUSTOM_AGENT_MODULE')
custom_agent_class = os.getenv('CUSTOM_AGENT_CLASS')
if custom_agent_module and custom_agent_class:
# Load custom agent
loader = CustomAgentLoader()
app.state.agent_factory = loader.create_agent
print(f"Loaded custom agent: {custom_agent_class}")
else:
# Use default agent
from openhands.sdk.agent import Agent
app.state.agent_factory = lambda llm: Agent(llm=llm, tools=get_default_tools())
print("Using default OpenHands agent")
```
### 4.4 Enhanced Sandbox Service Integration
#### 4.4.1 Permission-Aware Sandbox Service
```python
# openhands/app_server/sandbox/enhanced_sandbox_spec_service.py
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo, EnhancedSandboxSpecInfo
from typing import Dict, List, Optional
class EnhancedSandboxSpecService(SandboxSpecService):
"""Enhanced sandbox service with permissions and custom agent support."""
def __init__(self, spec_registry: Dict[str, EnhancedSandboxSpecInfo]):
super().__init__()
self.spec_registry = spec_registry
def get_available_sandbox_specs(self, user_email: str, user_groups: List[str]) -> List[str]:
"""Get sandbox specs available to the user based on permissions."""
available_specs = []
for spec_key, spec in self.spec_registry.items():
if self._has_permission(spec, user_email, user_groups):
available_specs.append(spec_key)
return available_specs
def get_sandbox_spec_by_id(
self,
spec_id: str,
user_email: str,
user_groups: List[str]
) -> SandboxSpecInfo:
"""Get sandbox spec by ID with permission check."""
if spec_id not in self.spec_registry:
# Fall back to default specs for backward compatibility
return super().get_default_sandbox_specs()[0]
enhanced_spec = self.spec_registry[spec_id]
# Check permissions
if not self._has_permission(enhanced_spec, user_email, user_groups):
raise PermissionError(f"User {user_email} does not have access to spec {spec_id}")
# Convert to SandboxSpecInfo for existing infrastructure
return self._convert_to_sandbox_spec_info(enhanced_spec)
def _has_permission(
self,
spec: EnhancedSandboxSpecInfo,
user_email: str,
user_groups: List[str]
) -> bool:
"""Check if user has permission to use the sandbox spec."""
# Owner always has access
if spec.permissions.owner == user_email:
return True
# Check user permissions
if user_email in spec.permissions.users:
return True
# Check group permissions
for group in user_groups:
if group in spec.permissions.groups:
return True
return False
def _convert_to_sandbox_spec_info(self, enhanced_spec: EnhancedSandboxSpecInfo) -> SandboxSpecInfo:
"""Convert enhanced spec to standard SandboxSpecInfo."""
# Build environment variables including agent configuration
env_vars = {
'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server',
'OH_ENABLE_VNC': '0',
'LOG_JSON': 'true',
'OH_CONVERSATIONS_PATH': '/workspace/conversations',
'OH_BASH_EVENTS_DIR': '/workspace/bash_events',
'PYTHONUNBUFFERED': '1',
'ENV_LOG_LEVEL': '20',
**enhanced_spec.initial_env
}
# Add custom agent configuration if specified
if enhanced_spec.agent.type == "custom":
env_vars.update({
'CUSTOM_AGENT_MODULE': enhanced_spec.agent.module,
'CUSTOM_AGENT_CLASS': enhanced_spec.agent.class_name,
})
return SandboxSpecInfo(
id=enhanced_spec.id,
command=enhanced_spec.command,
initial_env=env_vars,
working_dir=enhanced_spec.working_dir,
)
def register_sandbox_spec(
self,
spec: EnhancedSandboxSpecInfo,
admin_user: str
) -> str:
"""Register a new sandbox spec (admin only)."""
spec_key = f"{spec.name}:{spec.version}"
# Validate spec
self._validate_sandbox_spec(spec)
# Store in registry
self.spec_registry[spec_key] = spec
return spec_key
def _validate_sandbox_spec(self, spec: EnhancedSandboxSpecInfo) -> None:
"""Validate sandbox spec for security and correctness."""
# Image validation
if not spec.id or not spec.id.strip():
raise ValueError("Image ID cannot be empty")
# Permission validation
if not spec.permissions.owner:
raise ValueError("Sandbox spec must have an owner")
# Agent validation for custom agents
if spec.agent.type == "custom":
if not spec.agent.module or not spec.agent.class_name:
raise ValueError("Custom agents must specify module and class_name")
```
### 4.5 Enhanced API Integration
#### 4.5.1 Enhanced Conversation Creation
```python
# openhands/server/routes/conversation_routes.py (enhanced)
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional, Dict, Any, List
from uuid import UUID
from openhands.app_server.sandbox.enhanced_sandbox_spec_service import EnhancedSandboxSpecService
from openhands.server.session.agent_session import AgentSession
from openhands.server.auth import get_current_user, get_user_groups
# Enhanced conversation creation request
class CreateConversationRequest(BaseModel):
initial_message: str
workspace_config: Optional[Dict[str, Any]] = None
# New field for custom sandbox spec
sandbox_spec_id: Optional[str] = None
@router.post("/conversations")
async def create_conversation(
request: CreateConversationRequest,
current_user: str = Depends(get_current_user),
user_groups: List[str] = Depends(get_user_groups),
sandbox_service: EnhancedSandboxSpecService = Depends(get_enhanced_sandbox_service)
) -> ConversationResponse:
"""Create conversation with optional custom sandbox spec."""
try:
if request.sandbox_spec_id:
# Use custom sandbox spec with permission check
sandbox_spec = sandbox_service.get_sandbox_spec_by_id(
request.sandbox_spec_id,
current_user,
user_groups
)
else:
# Use default sandbox spec
sandbox_spec = sandbox_service.get_default_sandbox_specs()[0]
# Create sandbox and conversation
sandbox = await sandbox_service.create_sandbox(sandbox_spec)
await wait_for_agent_server_ready(sandbox)
conversation = await create_conversation_with_sandbox(
sandbox=sandbox,
initial_message=request.initial_message,
workspace_config=request.workspace_config
)
return ConversationResponse(
conversation_id=conversation.id,
status="created",
sandbox_spec_id=request.sandbox_spec_id or "default"
)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
```
#### 4.5.2 Sandbox Spec Management API
```python
# openhands/server/routes/sandbox_spec_routes.py (new)
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from pydantic import BaseModel
from typing import List, Optional
import yaml
from openhands.app_server.sandbox.enhanced_sandbox_spec_service import EnhancedSandboxSpecService
from openhands.app_server.sandbox.sandbox_spec_models import EnhancedSandboxSpecInfo
from openhands.server.auth import get_current_user, get_user_groups, require_admin
router = APIRouter(prefix="/api/sandbox-specs", tags=["Sandbox Specs"])
@router.get("/")
async def list_available_sandbox_specs(
current_user: str = Depends(get_current_user),
user_groups: List[str] = Depends(get_user_groups),
sandbox_service: EnhancedSandboxSpecService = Depends(get_enhanced_sandbox_service)
) -> List[str]:
"""List sandbox specs available to the current user."""
return sandbox_service.get_available_sandbox_specs(current_user, user_groups)
@router.post("/")
async def register_sandbox_spec(
spec_data: EnhancedSandboxSpecInfo,
current_user: str = Depends(require_admin),
sandbox_service: EnhancedSandboxSpecService = Depends(get_enhanced_sandbox_service)
) -> Dict[str, str]:
"""Register a new sandbox spec (admin only)."""
try:
spec_key = sandbox_service.register_sandbox_spec(spec_data, current_user)
return {"spec_id": spec_key, "status": "registered"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/upload")
async def upload_custom_image(
dockerfile: UploadFile = File(...),
context: UploadFile = File(...),
spec: UploadFile = File(...),
current_user: str = Depends(get_current_user),
sandbox_service: EnhancedSandboxSpecService = Depends(get_enhanced_sandbox_service)
) -> Dict[str, str]:
"""Upload custom image with Dockerfile and context (with security validation)."""
try:
# Parse spec file
spec_content = await spec.read()
spec_data = yaml.safe_load(spec_content)
# Validate user has permission to create specs
if not _can_user_create_specs(current_user):
raise HTTPException(status_code=403, detail="User not authorized to create custom specs")
# Security validation of Dockerfile
dockerfile_content = await dockerfile.read()
_validate_dockerfile_security(dockerfile_content)
# Build image (implementation depends on build system)
image_id = await _build_custom_image(dockerfile_content, context, current_user)
# Create enhanced spec
enhanced_spec = EnhancedSandboxSpecInfo(**spec_data)
enhanced_spec.id = image_id
enhanced_spec.permissions.owner = current_user
# Register the spec
spec_key = sandbox_service.register_sandbox_spec(enhanced_spec, current_user)
return {"spec_id": spec_key, "image_id": image_id, "status": "uploaded"}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Upload failed: {str(e)}")
```
## 5. Implementation Plan
All implementation must pass existing lints and tests. New functionality requires comprehensive test coverage including unit tests, integration tests, and end-to-end scenarios.
### 5.1 Enhanced Sandbox Models and Permissions (M1)
#### 5.1.1 Enhanced Sandbox Specification Models
* `openhands/app_server/sandbox/sandbox_spec_models.py` (enhanced)
* `tests/unit/app_server/sandbox/test_enhanced_sandbox_spec_models.py`
Extend existing `SandboxSpecInfo` with `EnhancedSandboxSpecInfo` including agent metadata, permissions, and resource requirements. This is the **core requirement** identified by the engineer.
#### 5.1.2 Permission System Foundation
* `openhands/server/auth/permissions.py`
* `tests/unit/server/auth/test_permissions.py`
Implement user and group-based permission system for sandbox spec access control. This addresses the **security concerns** from V0 mentioned by the engineer.
**Demo**: Create enhanced sandbox specs with permission restrictions and verify access control works correctly.
### 5.2 Enhanced Sandbox Service (M2)
#### 5.2.1 Permission-Aware Sandbox Service
* `openhands/app_server/sandbox/enhanced_sandbox_spec_service.py`
* `tests/unit/app_server/sandbox/test_enhanced_sandbox_spec_service.py`
Extend existing `SandboxSpecService` with permission checks and enhanced spec management. This **builds on existing infrastructure** as the engineer suggested.
#### 5.2.2 Agent Server Startup Integration
* `openhands-agent-server/openhands/agent_server/custom_agent_loader.py`
* `tests/unit/agent_server/test_custom_agent_loader.py`
Implement custom agent loading mechanism in agent server startup process with configuration-driven agent instantiation.
**Demo**: Deploy custom agents using enhanced sandbox specs and verify permission-based access control works end-to-end.
### 5.3 Image Management and API Integration (M3)
#### 5.3.1 Secure Image Management
* `openhands/app_server/sandbox/image_builder.py`
* `openhands/app_server/security/dockerfile_validator.py`
* `tests/unit/app_server/sandbox/test_image_builder.py`
* `tests/unit/app_server/security/test_dockerfile_validator.py`
Implement both **pre-built image registration** and **secure user upload** workflows as identified by the engineer. This addresses the security issues from V0.
#### 5.3.2 Enhanced Conversation API
* `openhands/server/routes/conversation_routes.py` (enhanced)
* `openhands/server/routes/sandbox_spec_routes.py` (new)
* `tests/unit/server/routes/test_enhanced_conversation_routes.py`
* `tests/unit/server/routes/test_sandbox_spec_routes.py`
Enhance existing conversation creation API to support `sandbox_spec_id` parameter and add new sandbox spec management endpoints.
**Demo**: Create conversations with custom sandbox specs through existing API endpoints and demonstrate both pre-built and user-uploaded image workflows.
### 5.4 Advanced Security and Management (M4)
#### 5.4.1 Image Security Validation
* `openhands/app_server/security/image_scanner.py`
* `openhands/app_server/security/security_policies.py`
* `tests/unit/app_server/security/test_image_scanner.py`
Implement comprehensive security validation including image vulnerability scanning, Dockerfile analysis, and approval workflows.
#### 5.4.2 Spec Registry and Lifecycle Management
* `openhands/app_server/sandbox/spec_registry.py`
* `openhands/app_server/sandbox/spec_lifecycle.py`
* `tests/unit/app_server/sandbox/test_spec_registry.py`
Add persistent storage for enhanced sandbox specs, version management, and lifecycle policies (deprecation, cleanup).
**Demo**: Deploy multiple custom agents with different permission levels, demonstrate security validation workflows, and show proper spec lifecycle management.
---
## Key Alignment with Engineer's Approach
This revised implementation plan directly addresses the engineer's requirements:
1. **✅ Uses existing sandbox specs system** - Enhanced rather than replaced
2. **✅ Permissions as core requirement** - Moved to M1 instead of M4
3. **✅ Two image management approaches** - Pre-built registration and secure user uploads
4. **✅ Security-first design** - Addresses V0 security issues with comprehensive validation
5. **✅ Minimal infrastructure changes** - Builds on existing `SandboxSpecService` and conversation APIs

View File

@@ -0,0 +1,934 @@
# Dynamic Custom Agent Package Loading (Scenario 2)
## 1. Introduction
### 1.1 Problem Statement
OpenHands V1 architecture uses a fixed agent server image (`ghcr.io/openhands/agent-server:5f62cee-python`) that contains the default agent implementation. Users who want to customize agent behavior with pure Python packages that don't require additional system dependencies currently have no supported mechanism to deploy their custom agents without building entirely new Docker images.
This creates unnecessary complexity for the common use case where users simply want to:
- Customize agent prompts and reasoning logic
- Add new Python-based tools and capabilities
- Integrate with Python APIs and libraries already available in the base image
- Deploy agents with different LLM configurations or specialized workflows
The current approach forces all customization through the heavyweight Scenario 1 path (custom Docker images), even when the base agent server image already contains all necessary dependencies.
### 1.2 Proposed Solution
We propose implementing **Dynamic Custom Agent Package Loading** within the existing V1 agent server container. This allows users to deploy custom agents by providing Python packages that are downloaded, installed, and instantiated at runtime without requiring custom Docker images.
Users will be able to:
1. Package their custom agents as standard Python packages (pip-installable)
2. Specify agent package URLs (Git repositories, PyPI packages, or ZIP archives) in conversation creation
3. Have the agent server dynamically download and install the package at startup
4. Instantiate their custom agent within the existing container environment
5. Maintain full compatibility with the existing HTTP API (`/ask_agent` endpoint)
The solution leverages the existing V1 architecture's agent server container but extends the startup process to support dynamic agent loading based on environment configuration.
**Trade-offs**: This approach is limited to Python packages that can run within the existing agent server environment. Users needing custom system dependencies, non-Python tools, or different base images must use Scenario 1. However, this covers the majority of agent customization use cases with significantly reduced complexity.
## 2. User Interface
### 2.1 Custom Agent Package Structure
Users create a standard Python package with the required interface:
```python
# my_custom_agent/
setup.py
requirements.txt # Optional additional dependencies
my_custom_agent/
__init__.py
agent.py # Main agent implementation
tools.py # Custom tools (optional)
config.py # Agent configuration
```
### 2.2 Agent Implementation
```python
# my_custom_agent/agent.py
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.llm import LLM
from openhands.sdk.tool import Tool
from typing import List, Dict, Any
class MyCustomAgent(AgentBase):
"""Custom agent with specialized behavior."""
def __init__(self, llm: LLM, tools: List[Tool], config: Dict[str, Any] = None):
super().__init__(llm=llm, tools=tools)
self.config = config or {}
async def initialize(self) -> None:
"""Initialize custom agent resources."""
# Custom initialization logic
pass
# Factory function for agent creation
def create_agent(llm: LLM, tools: List[Tool], config: Dict[str, Any] = None) -> AgentBase:
"""Factory function to create the custom agent."""
return MyCustomAgent(llm=llm, tools=tools, config=config)
```
### 2.3 Package Entry Point
```python
# my_custom_agent/__init__.py
from .agent import create_agent, MyCustomAgent
__all__ = ['create_agent', 'MyCustomAgent']
```
### 2.4 Setup Configuration
```python
# setup.py
from setuptools import setup, find_packages
setup(
name="my-custom-agent",
version="1.0.0",
packages=find_packages(),
install_requires=[
# Only additional dependencies beyond base image
"requests>=2.25.0",
"beautifulsoup4>=4.9.0",
],
entry_points={
'openhands.agents': [
'my_custom_agent = my_custom_agent:create_agent',
],
},
)
```
### 2.5 Conversation Creation with Dynamic Agent Loading
Users create conversations by specifying the agent package URL:
```bash
# Create conversation with custom agent package
curl -X POST "https://api.openhands.ai/api/conversations" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_package_url": "git+https://github.com/user/my-custom-agent.git",
"initial_message": "Help me analyze this codebase",
"workspace": {
"type": "local",
"working_dir": "/workspace/project"
}
}'
```
Alternative package sources:
```bash
# PyPI package
"agent_package_url": "my-custom-agent==1.0.0"
# ZIP archive
"agent_package_url": "https://example.com/agents/my-custom-agent.zip"
# Private Git repository
"agent_package_url": "git+https://token@github.com/private/agent.git"
```
## 3. Other Context
### 3.1 Current V1 Architecture Overview
The OpenHands V1 architecture follows a distributed service model with clear separation between the main application server and agent execution environment. Understanding this architecture is crucial for implementing dynamic agent loading.
#### 3.1.1 Service Separation
The V1 system consists of three primary components:
1. **Main Server** (`openhands/app_server/`): Handles user requests, conversation management, and sandbox orchestration
2. **Agent Server** (`software-agent-sdk/openhands-agent-server/`): Executes agent logic and manages conversation state
3. **Action Execution Server**: Handles tool execution (bash commands, file operations) within sandboxed environments
#### 3.1.2 Communication Flow
The current communication pattern follows this sequence:
```
User Request → Main Server → HTTP API → Agent Server → Agent Instance → Tools → Response
```
This separation allows for:
- **Isolation**: Agent execution is isolated from the main application
- **Scalability**: Multiple agent servers can be spawned for different conversations
- **Security**: Sandboxed execution prevents agent actions from affecting the host system
- **Flexibility**: Different agent configurations can be deployed without affecting the main server
#### 3.1.3 Container Orchestration
The main server uses `DockerSandboxSpecService` to create and manage agent server containers:
- **Image Selection**: Currently hardcoded to `ghcr.io/openhands/agent-server:5f62cee-python`
- **Environment Configuration**: Passed via `initial_env` in `SandboxSpecInfo`
- **Network Isolation**: Each conversation gets its own container instance
- **Resource Management**: Memory and CPU limits enforced at container level
### 3.2 Agent Server Internal Architecture
#### 3.2.1 FastAPI Application Structure
The agent server is built as a FastAPI application with these key components:
- **Conversation Router** (`conversation_router.py`): Handles HTTP endpoints for agent interaction
- **Conversation Service** (`conversation_service.py`): Manages conversation lifecycle and state
- **Event Service** (`event_service.py`): Processes agent actions and observations
- **Dependencies** (`dependencies.py`): Provides dependency injection for services
#### 3.2.2 Agent Instantiation Pattern
Currently, agents are instantiated during server startup using a fixed pattern:
```python
# Simplified current pattern
agent = Agent(
llm=LLM(model="default-model", api_key="..."),
tools=[TerminalTool(), FileEditorTool(), ...]
)
```
This creates a single agent instance that serves all requests to that container.
#### 3.2.3 Request Processing Flow
When the `/ask_agent` endpoint receives a request:
1. **Request Validation**: `AskAgentRequest` is validated and parsed
2. **Conversation Lookup**: Conversation state is retrieved or created
3. **Agent Invocation**: The fixed agent instance processes the question
4. **Response Formatting**: Result is wrapped in `AskAgentResponse`
5. **HTTP Response**: JSON response sent back to main server
### 3.3 Software Agent SDK Integration Points
#### 3.3.1 Agent Interface Requirements
The `software-agent-sdk` defines the contract that all agents must follow:
- **AgentBase**: Abstract base class requiring `llm` and `tools` parameters
- **Tool Integration**: Agents must work with the standardized tool system
- **Event Handling**: Agents process events through the conversation framework
- **State Management**: Agents maintain conversation context through event streams
#### 3.3.2 Tool System Architecture
The tool system provides the foundation for agent capabilities:
- **Tool Registration**: Tools are registered globally and resolved by name
- **Execution Framework**: `ToolExecutor` classes handle action execution
- **Built-in Tools**: Standard tools (Terminal, FileEditor, Browser) are always available
- **Custom Tools**: Additional tools can be registered through the plugin system
#### 3.3.3 LLM Integration
Agents interact with language models through the SDK's LLM abstraction:
- **Provider Agnostic**: Supports multiple LLM providers through unified interface
- **Configuration**: LLM settings (model, API keys, parameters) are configurable
- **Response Processing**: Structured handling of LLM responses and tool calls
### 3.4 Dynamic Loading Technical Foundation
#### 3.4.1 Python Package Management
Our dynamic loading approach leverages Python's built-in package management:
- **pip install**: Supports Git repositories, PyPI packages, and archive files
- **importlib**: Enables runtime module importing and class instantiation
- **entry_points**: Provides standardized plugin discovery mechanism
- **sys.path**: Allows dynamic modification of Python module search paths
#### 3.4.2 Container Environment Considerations
The agent server container provides a controlled environment for dynamic loading:
- **Python Runtime**: Pre-installed Python 3.x with pip and common libraries
- **Network Access**: Required for downloading packages from external sources
- **File System**: Writable areas for package installation and caching
- **Security Context**: Isolated from host system with appropriate permissions
#### 3.4.3 State Management Implications
Dynamic agent loading affects conversation state management:
- **Agent Persistence**: Custom agents must maintain state across requests
- **Configuration Isolation**: Different conversations can use different agent configurations
- **Resource Cleanup**: Proper cleanup of agent resources when conversations end
- **Error Recovery**: Fallback mechanisms when custom agents fail to load or execute
## 4. Technical Design
### 4.1 Current V1 Agent Instantiation Flow
To understand how our proposal integrates with the existing system, it's important to first examine how agents are currently instantiated and executed in the V1 architecture.
#### 4.1.1 Current Agent Server Startup Process
In the current V1 flow, agent instantiation follows this sequence:
1. **Main Server Request**: When a user creates a conversation, the main server (`openhands/app_server`) creates a sandbox specification via `DockerSandboxSpecService.get_default_sandbox_specs()`
2. **Container Launch**: The sandbox service launches the agent server container using the hardcoded image `ghcr.io/openhands/agent-server:5f62cee-python`
3. **Agent Server Initialization**: The agent server container starts with the command `['--port', '8000']` and initializes a FastAPI application
4. **Default Agent Creation**: During startup, the agent server creates a default agent instance (typically from the software-agent-sdk) with standard tools and configuration
5. **HTTP API Ready**: The agent server exposes the `/api/conversations/{id}/ask_agent` endpoint, routing requests to the default agent instance
#### 4.1.2 Current Agent Execution Flow
When a user sends a message through the V1 API:
1. **HTTP Request**: Main server makes POST request to `http://agent-server:8000/api/conversations/{id}/ask_agent`
2. **Agent Router**: `conversation_router.py` receives the request and extracts the `AskAgentRequest`
3. **Conversation Service**: `ConversationService.ask_agent()` method is called with the user's question
4. **Event Service**: The request is forwarded to `EventService.ask_agent()` which manages the conversation state
5. **Agent Execution**: The default agent processes the question using its configured LLM and tools
6. **Response Return**: The agent's response is returned through the same HTTP chain back to the main server
#### 4.1.3 Limitations of Current Approach
The current system has several limitations for custom agent deployment:
- **Fixed Agent Implementation**: The agent server container contains a single, hardcoded agent implementation
- **Static Configuration**: Agent behavior cannot be modified without rebuilding the entire container
- **No Runtime Customization**: Users cannot specify different agent types or configurations per conversation
- **Deployment Complexity**: Any agent customization requires building and maintaining custom Docker images
### 4.2 Proposed Dynamic Agent Loading Architecture
Our proposal extends the current V1 flow by introducing dynamic agent loading capabilities while maintaining full backward compatibility with existing APIs and infrastructure.
#### 4.2.1 Enhanced Agent Server Startup Process
The modified startup process introduces agent selection based on environment configuration:
1. **Environment Detection**: During agent server startup, check for `CUSTOM_AGENT_PACKAGE_URL` environment variable
2. **Conditional Loading**: If custom agent URL is present, download and install the package; otherwise use default agent
3. **Agent Factory Creation**: Create an agent factory function that can instantiate either custom or default agents
4. **HTTP API Registration**: Register the same `/ask_agent` endpoint, but route to the dynamically selected agent
#### 4.2.2 Dynamic Package Installation Process
When a custom agent package URL is detected, the system performs these steps:
1. **Package Download**: Use `pip install` to download the package from Git, PyPI, or ZIP sources
2. **Dependency Resolution**: Install any additional Python dependencies specified in the package
3. **Module Import**: Use `importlib` to dynamically import the custom agent module
4. **Agent Instantiation**: Call the package's `create_agent()` factory function with LLM and tools
5. **Initialization**: Execute any custom initialization logic defined by the agent
6. **Caching**: Cache the agent instance for reuse across multiple requests
#### 4.2.3 Modified Execution Flow
The execution flow remains largely unchanged from the user's perspective, but internally:
1. **Same HTTP API**: The `/ask_agent` endpoint signature and behavior remain identical
2. **Dynamic Routing**: Requests are routed to either custom or default agent based on startup configuration
3. **Transparent Operation**: The main server is unaware of whether it's communicating with a custom or default agent
4. **Consistent Response Format**: All agents return responses in the same `AskAgentResponse` format
### 4.3 Integration Points and Modifications
#### 4.3.1 Sandbox Service Modifications
The main server's sandbox service requires minimal changes to support dynamic agent loading:
```python
# Current: Fixed environment for all conversations
def get_default_sandbox_specs():
return [SandboxSpecInfo(
id=AGENT_SERVER_IMAGE,
command=['--port', '8000'],
initial_env={...} # Standard environment
)]
# Enhanced: Dynamic environment based on conversation requirements
def create_dynamic_agent_sandbox_spec(agent_package_url: str):
return SandboxSpecInfo(
id=AGENT_SERVER_IMAGE, # Same base image
command=['--port', '8000'],
initial_env={
...standard_env,
'CUSTOM_AGENT_PACKAGE_URL': agent_package_url # New variable
}
)
```
#### 4.3.2 Agent Server Startup Modifications
The agent server startup process is enhanced to detect and load custom agents:
```python
# Current: Fixed agent creation
@app.on_event("startup")
async def startup_event():
app.state.agent = DefaultAgent(llm=default_llm, tools=default_tools)
# Enhanced: Dynamic agent creation
@app.on_event("startup")
async def startup_event():
custom_agent_url = os.getenv('CUSTOM_AGENT_PACKAGE_URL')
if custom_agent_url:
loader = DynamicAgentLoader()
app.state.agent = await loader.load_agent_from_url(custom_agent_url, ...)
else:
app.state.agent = DefaultAgent(llm=default_llm, tools=default_tools)
```
#### 4.3.3 Conversation Service Integration
The conversation service routing logic is updated to use the dynamically loaded agent:
```python
# Current: Direct agent usage
async def ask_agent(self, conversation_id: UUID, question: str) -> str:
event_service = self.event_services[conversation_id]
return await event_service.ask_agent(question)
# Enhanced: Dynamic agent resolution
async def ask_agent(self, conversation_id: UUID, question: str) -> str:
event_service = self.event_services[conversation_id]
# Agent is now dynamically determined at startup
return await event_service.ask_agent(question)
```
### 4.4 Dynamic Agent Loading Implementation
#### 4.4.1 Agent Package Loader
```python
# openhands/agent_server/dynamic_agent_loader.py
import subprocess
import importlib
import tempfile
import os
import sys
from typing import Dict, Any, Optional
from urllib.parse import urlparse
from pathlib import Path
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.llm import LLM
from openhands.sdk.tool import Tool
from openhands.sdk.logger import get_logger
logger = get_logger(__name__)
class DynamicAgentLoader:
"""Loads custom agents from package URLs at runtime."""
def __init__(self):
self.installed_packages: Dict[str, str] = {}
async def load_agent_from_url(
self,
package_url: str,
llm: LLM,
tools: list[Tool],
config: Optional[Dict[str, Any]] = None
) -> AgentBase:
"""Load and instantiate agent from package URL."""
# Check if already installed
if package_url in self.installed_packages:
package_name = self.installed_packages[package_url]
return await self._create_agent_instance(package_name, llm, tools, config)
# Install the package
package_name = await self._install_package(package_url)
self.installed_packages[package_url] = package_name
# Create agent instance
return await self._create_agent_instance(package_name, llm, tools, config)
async def _install_package(self, package_url: str) -> str:
"""Install package from URL and return package name."""
logger.info(f"Installing custom agent package: {package_url}")
try:
# Install package using pip
result = subprocess.run([
sys.executable, "-m", "pip", "install", package_url
], capture_output=True, text=True, check=True)
logger.info(f"Package installation successful: {result.stdout}")
# Extract package name from URL
package_name = self._extract_package_name(package_url)
return package_name
except subprocess.CalledProcessError as e:
logger.error(f"Failed to install package {package_url}: {e.stderr}")
raise RuntimeError(f"Package installation failed: {e.stderr}")
def _extract_package_name(self, package_url: str) -> str:
"""Extract package name from various URL formats."""
if package_url.startswith('git+'):
# Git URL: extract repo name
url = package_url.replace('git+', '')
return Path(urlparse(url).path).stem
elif '==' in package_url:
# PyPI with version: extract package name
return package_url.split('==')[0]
elif package_url.endswith('.zip'):
# ZIP file: extract filename
return Path(urlparse(package_url).path).stem
else:
# Assume it's a simple package name
return package_url
async def _create_agent_instance(
self,
package_name: str,
llm: LLM,
tools: list[Tool],
config: Optional[Dict[str, Any]] = None
) -> AgentBase:
"""Create agent instance from installed package."""
try:
# Import the package
module = importlib.import_module(package_name)
# Look for create_agent function
if hasattr(module, 'create_agent'):
create_agent_func = getattr(module, 'create_agent')
agent = create_agent_func(llm=llm, tools=tools, config=config)
else:
# Fallback: look for agent class
agent_classes = [
attr for attr in dir(module)
if (isinstance(getattr(module, attr), type) and
issubclass(getattr(module, attr), AgentBase) and
getattr(module, attr) != AgentBase)
]
if not agent_classes:
raise RuntimeError(f"No agent class found in package {package_name}")
agent_class = getattr(module, agent_classes[0])
agent = agent_class(llm=llm, tools=tools, config=config)
# Initialize the agent
if hasattr(agent, 'initialize'):
await agent.initialize()
logger.info(f"Successfully created agent from package: {package_name}")
return agent
except Exception as e:
logger.error(f"Failed to create agent from package {package_name}: {e}")
raise RuntimeError(f"Agent instantiation failed: {e}")
```
#### 4.1.2 Agent Server Integration
```python
# Modified openhands/agent_server/conversation_service.py
import os
from typing import Optional
from openhands.agent_server.dynamic_agent_loader import DynamicAgentLoader
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.agent import Agent # Default agent
class ConversationService:
"""Enhanced conversation service with dynamic agent loading."""
def __init__(self, config: Config):
self.config = config
self.agent_loader = DynamicAgentLoader()
self._default_agent_factory = None
self._custom_agent_cache: Dict[str, AgentBase] = {}
async def _get_or_create_agent(
self,
conversation_id: UUID,
llm: LLM,
tools: list[Tool]
) -> AgentBase:
"""Get or create agent for conversation."""
# Check for custom agent package URL in environment
custom_agent_url = os.getenv('CUSTOM_AGENT_PACKAGE_URL')
if custom_agent_url:
# Use custom agent
if custom_agent_url not in self._custom_agent_cache:
agent = await self.agent_loader.load_agent_from_url(
package_url=custom_agent_url,
llm=llm,
tools=tools,
config=self._get_agent_config()
)
self._custom_agent_cache[custom_agent_url] = agent
return self._custom_agent_cache[custom_agent_url]
else:
# Use default agent
if not self._default_agent_factory:
self._default_agent_factory = Agent(llm=llm, tools=tools)
return self._default_agent_factory
def _get_agent_config(self) -> Dict[str, Any]:
"""Extract agent configuration from environment."""
config = {}
# Parse JSON config if provided
config_json = os.getenv('CUSTOM_AGENT_CONFIG')
if config_json:
import json
config.update(json.loads(config_json))
return config
```
### 4.2 Sandbox Service Integration
#### 4.2.1 Enhanced Sandbox Specification
```python
# openhands/app_server/sandbox/docker_sandbox_spec_service.py
from typing import Optional
class DockerSandboxSpecService(SandboxSpecService):
"""Enhanced sandbox service supporting dynamic agent loading."""
def create_dynamic_agent_sandbox_spec(
self,
agent_package_url: str,
agent_config: Optional[Dict[str, Any]] = None
) -> SandboxSpecInfo:
"""Create sandbox spec with dynamic agent loading configuration."""
# Base environment from existing implementation
base_env = {
'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server',
'OH_ENABLE_VNC': '0',
'LOG_JSON': 'true',
'OH_CONVERSATIONS_PATH': '/workspace/conversations',
'OH_BASH_EVENTS_DIR': '/workspace/bash_events',
'PYTHONUNBUFFERED': '1',
'ENV_LOG_LEVEL': '20',
}
# Add dynamic agent configuration
dynamic_env = {
**base_env,
'CUSTOM_AGENT_PACKAGE_URL': agent_package_url,
}
# Add agent configuration as JSON if provided
if agent_config:
import json
dynamic_env['CUSTOM_AGENT_CONFIG'] = json.dumps(agent_config)
return SandboxSpecInfo(
id=AGENT_SERVER_IMAGE, # Same base image
command=['--port', '8000'],
initial_env=dynamic_env,
working_dir='/workspace/project',
)
```
#### 4.2.2 Conversation Creation API Enhancement
```python
# openhands/server/routes/conversation_routes.py
from pydantic import BaseModel
from typing import Optional, Dict, Any
class CreateConversationRequest(BaseModel):
"""Enhanced conversation creation request."""
initial_message: str
workspace_config: Optional[Dict[str, Any]] = None
# New field for dynamic agent loading
agent_package_url: Optional[str] = None
agent_config: Optional[Dict[str, Any]] = None
@router.post("/conversations")
async def create_conversation(
request: CreateConversationRequest,
sandbox_service: DockerSandboxSpecService = Depends(get_sandbox_service)
) -> ConversationResponse:
"""Create conversation with optional dynamic agent loading."""
if request.agent_package_url:
# Create sandbox with dynamic agent loading
sandbox_spec = sandbox_service.create_dynamic_agent_sandbox_spec(
agent_package_url=request.agent_package_url,
agent_config=request.agent_config
)
else:
# Use default sandbox specification
sandbox_spec = sandbox_service.get_default_sandbox_specs()[0]
# Create sandbox and conversation
sandbox = await sandbox_service.create_sandbox(sandbox_spec)
await wait_for_agent_server_ready(sandbox)
conversation = await create_conversation_with_sandbox(
sandbox=sandbox,
initial_message=request.initial_message,
workspace_config=request.workspace_config
)
return ConversationResponse(
conversation_id=conversation.id,
status="created",
agent_type="custom" if request.agent_package_url else "default"
)
```
### 4.3 Agent Server Startup Process
#### 4.3.1 Enhanced Agent Server Initialization
```python
# openhands/agent_server/api.py startup modification
import os
from openhands.agent_server.dynamic_agent_loader import DynamicAgentLoader
@app.on_event("startup")
async def startup_event():
"""Enhanced startup with dynamic agent loading support."""
# Initialize dynamic agent loader
app.state.agent_loader = DynamicAgentLoader()
# Check for custom agent package URL
custom_agent_url = os.getenv('CUSTOM_AGENT_PACKAGE_URL')
if custom_agent_url:
logger.info(f"Dynamic agent loading enabled: {custom_agent_url}")
# Pre-validate package URL (optional)
try:
await app.state.agent_loader._install_package(custom_agent_url)
logger.info("Custom agent package pre-installed successfully")
except Exception as e:
logger.warning(f"Custom agent pre-installation failed: {e}")
# Continue with startup - will retry on first conversation
else:
logger.info("Using default agent configuration")
```
### 4.4 Error Handling and Fallback
#### 4.4.1 Robust Error Handling
```python
# openhands/agent_server/dynamic_agent_loader.py (enhanced)
class DynamicAgentLoader:
"""Enhanced loader with comprehensive error handling."""
async def load_agent_with_fallback(
self,
package_url: str,
llm: LLM,
tools: list[Tool],
config: Optional[Dict[str, Any]] = None
) -> AgentBase:
"""Load custom agent with fallback to default agent."""
try:
return await self.load_agent_from_url(package_url, llm, tools, config)
except Exception as e:
logger.error(f"Custom agent loading failed: {e}")
logger.info("Falling back to default agent")
# Import default agent
from openhands.sdk.agent import Agent
return Agent(llm=llm, tools=tools)
async def _validate_package_url(self, package_url: str) -> bool:
"""Validate package URL accessibility."""
try:
if package_url.startswith('git+'):
# Validate Git repository access
import subprocess
result = subprocess.run([
'git', 'ls-remote', package_url.replace('git+', '')
], capture_output=True, timeout=30)
return result.returncode == 0
elif package_url.startswith('http'):
# Validate HTTP URL accessibility
import httpx
async with httpx.AsyncClient() as client:
response = await client.head(package_url, timeout=30)
return response.status_code == 200
else:
# Assume PyPI package - always return True
return True
except Exception:
return False
```
### 4.5 Security and Isolation
#### 4.5.1 Package Security Validation
```python
# openhands/agent_server/security/package_validator.py
import re
from typing import List, Set
from urllib.parse import urlparse
class PackageSecurityValidator:
"""Validates custom agent packages for security compliance."""
ALLOWED_DOMAINS: Set[str] = {
'github.com',
'gitlab.com',
'bitbucket.org',
'pypi.org',
'files.pythonhosted.org'
}
BLOCKED_PACKAGES: Set[str] = {
# Add known malicious packages
}
def validate_package_url(self, package_url: str) -> bool:
"""Validate package URL against security policies."""
# Check blocked packages
if self._is_blocked_package(package_url):
return False
# Validate domain for HTTP/Git URLs
if package_url.startswith(('http', 'git+')):
parsed = urlparse(package_url.replace('git+', ''))
if parsed.hostname not in self.ALLOWED_DOMAINS:
return False
# Additional security checks
return self._validate_package_name(package_url)
def _is_blocked_package(self, package_url: str) -> bool:
"""Check if package is in blocklist."""
for blocked in self.BLOCKED_PACKAGES:
if blocked in package_url.lower():
return True
return False
def _validate_package_name(self, package_url: str) -> bool:
"""Validate package name format."""
# Basic validation for malicious patterns
malicious_patterns = [
r'\.\./', # Path traversal
r'[;&|`$]', # Command injection
r'<script', # XSS attempts
]
for pattern in malicious_patterns:
if re.search(pattern, package_url, re.IGNORECASE):
return False
return True
```
## 5. Implementation Plan
All implementation must pass existing lints and tests. New functionality requires comprehensive test coverage including unit tests, integration tests, and end-to-end scenarios.
### 5.1 Dynamic Agent Loading Foundation (M1)
#### 5.1.1 Dynamic Agent Loader Implementation
* `openhands/agent_server/dynamic_agent_loader.py`
* `tests/unit/agent_server/test_dynamic_agent_loader.py`
Implement core dynamic agent loading functionality with package installation, module importing, and agent instantiation.
#### 5.1.2 Package Security Validation
* `openhands/agent_server/security/package_validator.py`
* `tests/unit/agent_server/security/test_package_validator.py`
Add security validation for custom agent packages including domain allowlists and malicious pattern detection.
**Demo**: Load a simple custom agent from a Git repository and verify it responds to basic queries through the existing `/ask_agent` HTTP API.
### 5.2 Sandbox Service Integration (M2)
#### 5.2.1 Enhanced Sandbox Specification
* `openhands/app_server/sandbox/docker_sandbox_spec_service.py` (modifications)
* `tests/unit/app_server/sandbox/test_docker_sandbox_spec_service.py` (enhancements)
Extend existing sandbox service to support dynamic agent loading configuration through environment variables.
#### 5.2.2 Agent Server Startup Integration
* `openhands/agent_server/conversation_service.py` (modifications)
* `openhands/agent_server/api.py` (startup enhancements)
* `tests/unit/agent_server/test_conversation_service.py` (enhancements)
Integrate dynamic agent loading into agent server startup and conversation management processes.
**Demo**: Create conversations with custom agents specified via environment variables and demonstrate proper agent instantiation and tool execution.
### 5.3 API Integration (M3)
#### 5.3.1 Enhanced Conversation Creation API
* `openhands/server/routes/conversation_routes.py` (modifications)
* `tests/unit/server/routes/test_conversation_routes.py` (enhancements)
Extend conversation creation API to accept agent package URLs and configuration parameters.
#### 5.3.2 Error Handling and Fallback
* `openhands/agent_server/dynamic_agent_loader.py` (enhancements)
* `tests/unit/agent_server/test_dynamic_agent_fallback.py`
Implement comprehensive error handling with fallback to default agents when custom agent loading fails.
**Demo**: Create conversations through API endpoints with various package URL formats (Git, PyPI, ZIP) and demonstrate proper error handling and fallback behavior.
### 5.4 Advanced Features and Optimization (M4)
#### 5.4.1 Agent Caching and Performance
* `openhands/agent_server/agent_cache.py`
* `tests/unit/agent_server/test_agent_cache.py`
Implement agent instance caching to avoid repeated package installation and improve performance for multiple conversations with the same custom agent.
#### 5.4.2 Package Management and Cleanup
* `openhands/agent_server/package_manager.py`
* `tests/unit/agent_server/test_package_manager.py`
Add package lifecycle management including cleanup of unused packages and version management for package updates.
**Demo**: Deploy multiple conversations with different custom agents simultaneously and demonstrate proper resource management, caching, and cleanup behavior.
---
## References
This design document is based on analysis of the following source materials:
1. **OpenHands V1 Architecture**: Analysis of `openhands/app_server/sandbox/docker_sandbox_spec_service.py` and `openhands/app_server/event_callback/github_v1_callback_processor.py` for understanding the V1 flow and agent server integration.
2. **Software Agent SDK**: Analysis of the `software-agent-sdk` repository, specifically:
- `openhands-agent-server/openhands/agent_server/conversation_router.py` for HTTP API patterns
- `openhands-sdk/openhands/sdk/agent/base.py` for agent interface requirements
- `examples/01_standalone_sdk/02_custom_tools.py` for custom agent implementation patterns
3. **Agent Server Models**: Analysis of `openhands.agent_server.models` imports in the main OpenHands codebase for understanding the API contract between main server and agent server.
4. **Container Architecture**: Analysis of `AGENT_SERVER_IMAGE` constant usage in `openhands/app_server/sandbox/sandbox_spec_service.py` for understanding the current container deployment model.
All technical specifications and implementation details are derived from examination of the existing codebase and established patterns within the OpenHands ecosystem.

View File

@@ -7,8 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-31536c8-python}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -23,27 +23,17 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install poetry and export before importing current code.
RUN /app/.venv/bin/pip install poetry poetry-plugin-export
# Install Python dependencies from poetry.lock for reproducible builds
# Copy lock files first for better Docker layer caching
COPY --chown=openhands:openhands enterprise/pyproject.toml enterprise/poetry.lock /tmp/enterprise/
RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise
cd /app && \
rm -rf /tmp/enterprise && \
/app/.venv/bin/pip uninstall -y poetry poetry-plugin-export
# Install Python packages with security fixes
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
# Update packages with known CVE fixes
pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"
WORKDIR /app
COPY --chown=openhands:openhands --chmod=770 enterprise .
COPY enterprise .
RUN chown -R openhands:openhands /app && chmod -R 770 /app
USER openhands
# Command will be overridden by Kubernetes deployment template

View File

@@ -2,7 +2,7 @@ BACKEND_HOST ?= "127.0.0.1"
BACKEND_PORT = 3000
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
FRONTEND_PORT = 3001
OPENHANDS_PATH ?= ".."
OPENHANDS_PATH ?= "../../OpenHands"
OPENHANDS := $(OPENHANDS_PATH)
OPENHANDS_FRONTEND_PATH = $(OPENHANDS)/frontend/build

View File

@@ -1,6 +1,6 @@
# OpenHands Enterprise Server
> [!WARNING]
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.openhands.dev/contact).
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.all-hands.dev/contact).
> [!WARNING]
> This is a work in progress and may contain bugs, incomplete features, or breaking changes.
@@ -10,13 +10,13 @@ This directory contains the enterprise server used by [OpenHands Cloud](https://
You may also want to check out the MIT-licensed [OpenHands](https://github.com/OpenHands/OpenHands)
## Extension of OpenHands
## Extension of OpenHands (OSS)
The code in `/enterprise` builds on top of OpenHands (MIT-licensed), extending its functionality. The enterprise code is entangled with OpenHands in two ways:
The code in `/enterprise` directory builds on top of open source (OSS) code, extending its functionality. The enterprise code is entangled with the OSS code in two ways
- Enterprise stacks on top of OpenHands. For example, the middleware in enterprise is stacked right on top of the middlewares in OpenHands. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise stacks on top of OSS. For example, the middleware in enterprise is stacked right on top of the middlewares in OSS. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise overrides the implementation in OpenHands (only one is present at a time). For example, the server config SaasServerConfig overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) in OpenHands. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
Key areas that change on `SAAS` are
@@ -26,11 +26,11 @@ Key areas that change on `SAAS` are
### Authentication
| Aspect | OpenHands | Enterprise |
| Aspect | OSS | Enterprise |
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The GitHub app provides a short-lived access token and refresh token |
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The Github app provides a short-lived access token and refresh token |
| **Token Storage** | PAT is stored in **Settings** | Token is stored in **GithubTokenManager** (a file store in our backend) |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during OAuth, so subsequent requests with the cookie can be considered authenticated |
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during oauth, so subsequent requests with the cookie can be considered authenticated |
Note that in the future, authentication will happen via keycloak. All modifications for authentication will happen in enterprise.
@@ -38,7 +38,7 @@ Note that in the future, authentication will happen via keycloak. All modificati
The github service is responsible for interacting with Github APIs. As a consequence, it uses the user's token and refreshes it if need be
| Aspect | OpenHands | Enterprise |
| Aspect | OSS | Enterprise |
| ------------------------- | -------------------------------------- | ---------------------------------------------- |
| **Class used** | `GitHubService` | `SaaSGitHubService` |
| **Token used** | User's PAT fetched from `Settings` | User's token fetched from `GitHubTokenManager` |
@@ -50,7 +50,7 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
## User ID vs User Token
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On OSS, the entire APP revolves around the Github token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
Note that introducing Github User ID on OSS, for instance, will cause large breakages.

View File

@@ -721,7 +721,6 @@
"https://$WEB_HOST/oauth/keycloak/callback",
"https://$WEB_HOST/oauth/keycloak/offline/callback",
"https://$WEB_HOST/slack/keycloak-callback",
"https://$WEB_HOST/oauth/device/keycloak-callback",
"https://$WEB_HOST/api/email/verified",
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
],

View File

@@ -1,207 +0,0 @@
#!/usr/bin/env python
"""
This script can be removed once orgs is established - probably after Feb 15 2026
Downgrade script for migrated users.
This script identifies users who have been migrated (already_migrated=True)
and reverts them back to the pre-migration state.
Usage:
# Dry run - just list the users that would be downgraded
python downgrade_migrated_users.py --dry-run
# Downgrade a specific user by their keycloak_user_id
python downgrade_migrated_users.py --user-id <user_id>
# Downgrade all migrated users (with confirmation)
python downgrade_migrated_users.py --all
# Downgrade all migrated users without confirmation (dangerous!)
python downgrade_migrated_users.py --all --no-confirm
"""
import argparse
import asyncio
import sys
# Add the enterprise directory to the path
sys.path.insert(0, '/workspace/project/OpenHands/enterprise')
from server.logger import logger
from sqlalchemy import select, text
from storage.database import session_maker
from storage.user_settings import UserSettings
from storage.user_store import UserStore
def get_migrated_users() -> list[str]:
"""Get list of keycloak_user_ids for users who have been migrated.
This includes:
1. Users with already_migrated=True in user_settings (migrated users)
2. Users in the 'user' table who don't have a user_settings entry (new sign-ups)
"""
with session_maker() as session:
# Get users from user_settings with already_migrated=True
migrated_result = session.execute(
select(UserSettings.keycloak_user_id).where(
UserSettings.already_migrated.is_(True)
)
)
migrated_users = {row[0] for row in migrated_result.fetchall() if row[0]}
# Get users from the 'user' table (new sign-ups won't have user_settings)
# These are users who signed up after the migration was deployed
new_signup_result = session.execute(
text("""
SELECT CAST(u.id AS VARCHAR)
FROM "user" u
WHERE NOT EXISTS (
SELECT 1 FROM user_settings us
WHERE us.keycloak_user_id = CAST(u.id AS VARCHAR)
)
""")
)
new_signups = {row[0] for row in new_signup_result.fetchall() if row[0]}
# Combine both sets
all_users = migrated_users | new_signups
return list(all_users)
async def downgrade_user(user_id: str) -> bool:
"""Downgrade a single user.
Args:
user_id: The keycloak_user_id to downgrade
Returns:
True if successful, False otherwise
"""
try:
result = await UserStore.downgrade_user(user_id)
if result:
print(f'✓ Successfully downgraded user: {user_id}')
return True
else:
print(f'✗ Failed to downgrade user: {user_id}')
return False
except Exception as e:
print(f'✗ Error downgrading user {user_id}: {e}')
logger.exception(
'downgrade_script:error',
extra={'user_id': user_id, 'error': str(e)},
)
return False
async def main():
parser = argparse.ArgumentParser(
description='Downgrade migrated users back to pre-migration state'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Just list users that would be downgraded, without making changes',
)
parser.add_argument(
'--user-id',
type=str,
help='Downgrade a specific user by keycloak_user_id',
)
parser.add_argument(
'--all',
action='store_true',
help='Downgrade all migrated users',
)
parser.add_argument(
'--no-confirm',
action='store_true',
help='Skip confirmation prompt (use with caution!)',
)
args = parser.parse_args()
# Get list of migrated users
migrated_users = get_migrated_users()
print(f'\nFound {len(migrated_users)} migrated user(s).')
if args.dry_run:
print('\n--- DRY RUN MODE ---')
print('The following users would be downgraded:')
for user_id in migrated_users:
print(f' - {user_id}')
print('\nNo changes were made.')
return
if args.user_id:
# Downgrade a specific user
if args.user_id not in migrated_users:
print(f'\nUser {args.user_id} is not in the migrated users list.')
print('Either the user was not migrated, or the user_id is incorrect.')
return
print(f'\nDowngrading user: {args.user_id}')
if not args.no_confirm:
confirm = input('Are you sure? (yes/no): ')
if confirm.lower() != 'yes':
print('Cancelled.')
return
success = await downgrade_user(args.user_id)
if success:
print('\nDowngrade completed successfully.')
else:
print('\nDowngrade failed. Check logs for details.')
sys.exit(1)
elif args.all:
# Downgrade all migrated users
if not migrated_users:
print('\nNo migrated users to downgrade.')
return
print(f'\n⚠️ About to downgrade {len(migrated_users)} user(s).')
if not args.no_confirm:
print('\nThis will:')
print(' - Revert LiteLLM team/user budget settings')
print(' - Delete organization entries')
print(' - Delete user entries in the new schema')
print(' - Reset the already_migrated flag')
print('\nUsers to downgrade:')
for user_id in migrated_users[:10]: # Show first 10
print(f' - {user_id}')
if len(migrated_users) > 10:
print(f' ... and {len(migrated_users) - 10} more')
confirm = input('\nType "yes" to proceed: ')
if confirm.lower() != 'yes':
print('Cancelled.')
return
print('\nStarting downgrade...\n')
success_count = 0
fail_count = 0
for user_id in migrated_users:
success = await downgrade_user(user_id)
if success:
success_count += 1
else:
fail_count += 1
print('\n--- Summary ---')
print(f'Successful: {success_count}')
print(f'Failed: {fail_count}')
if fail_count > 0:
sys.exit(1)
else:
parser.print_help()
print('\nPlease specify --dry-run, --user-id, or --all')
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -2,7 +2,7 @@
You have a few options here, which are expanded on below:
- A simple local development setup, with live reloading for both OpenHands and this repo
- A simple local development setup, with live reloading for both OSS and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
@@ -26,7 +26,7 @@ Before starting, make sure you have the following tools installed:
## Option 1: Simple local development
This option will allow you to modify both the OpenHands code and the code in this repo,
This option will allow you to modify the both the OSS code and the code in this repo,
and see the changes in real-time.
This option works best for most scenarios. The only thing it's missing is
@@ -50,7 +50,7 @@ First run this to retrieve Github App secrets
```
gcloud auth application-default login
gcloud config set project global-432717
enterprise_local/decrypt_env.sh /path/to/root/of/deploy/repo
local/decrypt_env.sh
```
Now run this to generate a `.env` file, which will used to run SAAS locally
@@ -105,9 +105,9 @@ export REDIS_PORT=6379
(see above)
### 2. Build OpenHands
### 2. Build OSS Openhands
Develop on [Openhands](https://github.com/OpenHands/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
@@ -155,7 +155,7 @@ Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-
### Local Debugging with VSCode
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of OpenHands running.
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
#### Redis
@@ -201,8 +201,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
@@ -235,8 +235,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",

View File

@@ -116,7 +116,7 @@ lines.append('POSTHOG_CLIENT_KEY=test')
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-opus-4-5-20251101')
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
lines.append('LOCAL_DEPLOYMENT=true')
lines.append('DB_HOST=localhost')

4
enterprise/enterprise_local/decrypt_env.sh Executable file → Normal file
View File

@@ -4,12 +4,12 @@ set -euo pipefail
# Check if DEPLOY_DIR argument was provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <DEPLOY_DIR>"
echo "Example: $0 /path/to/root/of/deploy/repo"
echo "Example: $0 /path/to/deploy"
exit 1
fi
# Normalize path (remove trailing slash)
DEPLOY_DIR="${1%/}"
DEPLOY_DIR="${DEPLOY_DIR%/}"
# Function to decrypt and rename
decrypt_and_move() {

View File

@@ -28,11 +28,9 @@ class SaaSExperimentManager(ExperimentManager):
return agent
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
# Skip experiment for planning agents which require their specialized prompt
if agent.system_prompt_filename != 'system_prompt_planning.j2':
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
return agent

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from enum import Enum
from typing import Any
from github import Auth, Github, GithubIntegration
from github import Github, GithubIntegration
from integrations.github.github_view import (
GithubIssue,
)
@@ -84,7 +84,7 @@ class GitHubDataCollector:
# self.full_saved_pr_path = 'github_data/prs/{}-{}/data.json'
self.full_saved_pr_path = 'prs/github/{}-{}/data.json'
self.github_integration = GithubIntegration(
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
)
self.conversation_id = None
@@ -143,7 +143,7 @@ class GitHubDataCollector:
try:
installation_token = self._get_installation_access_token(installation_id)
with Github(auth=Auth.Token(installation_token)) as github_client:
with Github(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(auth=Auth.Token(installation_token)) as github_client:
with Github(installation_token) as github_client:
repo = github_client.get_repo(repo_name)
pr = repo.get_pull(pr_number)

View File

@@ -1,6 +1,6 @@
from types import MappingProxyType
from github import Auth, Github, GithubIntegration
from github import Github, GithubIntegration
from integrations.github.data_collector import GitHubDataCollector
from integrations.github.github_solvability import summarize_issue_solvability
from integrations.github.github_view import (
@@ -21,24 +21,16 @@ from integrations.utils import (
CONVERSATION_URL,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.auth_error import ExpiredError
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.service_types import AuthenticationError
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.secrets import Secrets
from openhands.utils.async_utils import call_sync_from_async
@@ -50,7 +42,7 @@ class GithubManager(Manager):
self.token_manager = token_manager
self.data_collector = data_collector
self.github_integration = GithubIntegration(
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
)
self.jinja_env = Environment(
@@ -84,7 +76,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(auth=Auth.Token(installation_token)) as github_client:
with Github(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):
@@ -145,7 +137,11 @@ class GithubManager(Manager):
).get('body', ''):
return False
# Check event types before making expensive API calls (e.g., _user_has_write_access_to_repo)
if GithubFactory.is_eligible_for_conversation_starter(
message
) and self._user_has_write_access_to_repo(installation_id, repo_name, username):
await GithubFactory.trigger_conversation_starter(message)
if not (
GithubFactory.is_labeled_issue(message)
or GithubFactory.is_issue_comment(message)
@@ -155,17 +151,8 @@ class GithubManager(Manager):
return False
logger.info(f'[GitHub] Checking permissions for {username} in {repo_name}')
user_has_write_access = self._user_has_write_access_to_repo(
installation_id, repo_name, username
)
if (
GithubFactory.is_eligible_for_conversation_starter(message)
and user_has_write_access
):
await GithubFactory.trigger_conversation_starter(message)
return user_has_write_access
return self._user_has_write_access_to_repo(installation_id, repo_name, username)
async def receive_message(self, message: Message):
self._confirm_incoming_source_type(message)
@@ -177,13 +164,8 @@ class GithubManager(Manager):
)
if await self.is_job_requested(message):
payload = message.message.get('payload', {})
user_id = payload['sender']['id']
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
github_view = await GithubFactory.create_github_view_from_payload(
message, keycloak_user_id
message, self.token_manager
)
logger.info(
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
@@ -211,7 +193,7 @@ class GithubManager(Manager):
outgoing_message = message.message
if isinstance(github_view, GithubInlinePRComment):
with Github(auth=Auth.Token(installation_token)) as github_client:
with Github(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(
@@ -223,7 +205,7 @@ class GithubManager(Manager):
or isinstance(github_view, GithubIssueComment)
or isinstance(github_view, GithubIssue)
):
with Github(auth=Auth.Token(installation_token)) as github_client:
with Github(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)
@@ -300,15 +282,8 @@ class GithubManager(Manager):
f'[Github]: Error summarizing issue solvability: {str(e)}'
)
saas_user_auth = await get_saas_user_auth(
github_view.user_info.keycloak_user_id, self.token_manager
)
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
self.jinja_env, secret_store.provider_tokens, convo_metadata
)
conversation_id = github_view.conversation_id
@@ -317,7 +292,14 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
if not github_view.v1_enabled:
from openhands.server.shared import ConversationStoreImpl, config
conversation_store = await ConversationStoreImpl.get_instance(
config, github_view.user_info.keycloak_user_id
)
metadata = await conversation_store.get_metadata(conversation_id)
if metadata.conversation_version != 'v1':
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
@@ -354,13 +336,6 @@ class GithubManager(Manager):
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except (AuthenticationError, ExpiredError, SessionExpiredError) as e:
logger.warning(
f'[GitHub] Session expired for user {user_info.username}: {str(e)}'
)
msg_info = get_session_expired_message(user_info.username)
msg = self.create_outgoing_message(msg_info)
await self.send_message(msg, github_view)

View File

@@ -1,6 +1,6 @@
import asyncio
from integrations.store_repo_utils import store_repositories_in_db
from integrations.utils import store_repositories_in_db
from pydantic import SecretStr
from server.auth.token_manager import TokenManager

View File

@@ -1,7 +1,7 @@
import asyncio
import time
from github import Auth, Github
from github import 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(auth=Auth.Token(user_token)) as github_client:
with Github(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

@@ -1,7 +1,6 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from github import Auth, Github, GithubIntegration
from github import Github, GithubIntegration
from github.Issue import Issue
from integrations.github.github_types import (
WorkflowRun,
@@ -9,25 +8,23 @@ from integrations.github.github_types import (
WorkflowRunStatus,
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
ENABLE_V1_GITHUB_RESOLVER,
HOST,
HOST_URL,
get_oh_labels,
get_user_v1_enabled_setting,
has_exact_mention,
)
from jinja2 import Environment
from pydantic.dataclasses import dataclass
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -37,16 +34,18 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.sdk.conversation.secret_source import SecretSource
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
@@ -56,8 +55,47 @@ from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
class GithubUserContext(UserContext):
"""User context for GitHub integration that provides user info without web request."""
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
self.keycloak_user_id = keycloak_user_id
self.git_provider_tokens = git_provider_tokens
self.settings_store = SaasSettingsStore(
user_id=self.keycloak_user_id,
session_maker=session_maker,
config=get_config(),
)
self.secrets_store = SaasSecretsStore(
self.keycloak_user_id, session_maker, get_config()
)
async def get_user_id(self) -> str | None:
return self.keycloak_user_id
async def get_user_info(self) -> UserInfo:
user_settings = await self.settings_store.load()
return UserInfo(
id=self.keycloak_user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
return self.git_provider_tokens.get(ProviderType.GITHUB)
return None
async def get_secrets(self) -> dict[str, SecretSource]:
# Return empty dict for now - GitHub integration handles secrets separately
user_secrets = await self.secrets_store.load()
return dict(user_secrets.custom_secrets) if user_secrets else {}
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
@@ -78,17 +116,48 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
if not user_id:
return False
# Check global setting first - if disabled globally, return False
if not ENABLE_PROACTIVE_CONVERSATION_STARTERS:
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.enable_proactive_conversation_starters is None:
return False
def _get_setting():
org = OrgStore.get_current_org_from_keycloak_user_id(user_id)
if not org:
return False
return bool(org.enable_proactive_conversation_starters)
return settings.enable_proactive_conversation_starters
return await call_sync_from_async(_get_setting)
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
# If no user ID is provided, we can't check user settings
if not user_id:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.v1_enabled is None:
return False
return settings.v1_enabled
# =================================================
@@ -111,10 +180,6 @@ class GithubIssue(ResolverViewInterface):
title: str
description: str
previous_comments: list[Comment]
v1_enabled: bool
def _get_branch_name(self) -> str | None:
return getattr(self, 'branch_name', None)
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
@@ -149,7 +214,6 @@ class GithubIssue(ResolverViewInterface):
issue_body=self.description,
previous_comments=self.previous_comments,
)
return user_instructions, conversation_instructions
async def _get_user_secrets(self):
@@ -162,33 +226,14 @@ class GithubIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
self.conversation_id = uuid4().hex
return ConversationMetadata(
conversation_id=self.conversation_id,
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
selected_branch=None,
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
@@ -197,20 +242,24 @@ class GithubIssue(ResolverViewInterface):
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
return
except Exception as e:
logger.warning(f'Error checking V1 settings, falling back to V0: {e}')
# Use existing V0 conversation service
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
@@ -219,7 +268,6 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitHub]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
@@ -241,12 +289,10 @@ class GithubIssue(ResolverViewInterface):
async def _create_v1_conversation(
self,
jinja_env: Environment,
saas_user_auth: UserAuth,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[GitHub V1]: Creating V1 conversation')
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
@@ -268,7 +314,6 @@ class GithubIssue(ResolverViewInterface):
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
title=f'GitHub Issue #{self.issue_number}: {self.title}',
trigger=ConversationTrigger.RESOLVER,
@@ -278,7 +323,10 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
github_user_context = GithubUserContext(
keycloak_user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -295,7 +343,7 @@ class GithubIssue(ResolverViewInterface):
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from integrations.github.github_v1_callback_processor import (
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
@@ -327,6 +375,7 @@ class GithubIssueComment(GithubIssue):
conversation_instructions_template = jinja_env.get_template(
'issue_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
issue_number=self.issue_number,
issue_title=self.title,
@@ -362,6 +411,20 @@ class GithubPRComment(GithubIssueComment):
return user_instructions, conversation_instructions
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self.branch_name,
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
@dataclass
class GithubInlinePRComment(GithubPRComment):
@@ -396,6 +459,7 @@ class GithubInlinePRComment(GithubPRComment):
conversation_instructions_template = jinja_env.get_template(
'pr_update_conversation_instructions.j2'
)
conversation_instructions = conversation_instructions_template.render(
pr_number=self.issue_number,
pr_title=self.title,
@@ -410,7 +474,7 @@ class GithubInlinePRComment(GithubPRComment):
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from integrations.github.github_v1_callback_processor import (
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
@@ -675,13 +739,13 @@ class GithubFactory:
def _interact_with_github() -> Issue | None:
with GithubIntegration(
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
) as integration:
access_token = integration.get_access_token(
payload['installation']['id']
).token
with Github(auth=Auth.Token(access_token)) as gh:
with Github(access_token) as gh:
repo = gh.get_repo(selected_repo)
login = (
payload['organization']['login']
@@ -739,7 +803,7 @@ class GithubFactory:
@staticmethod
async def create_github_view_from_payload(
message: Message, keycloak_user_id: str
message: Message, token_manager: TokenManager
) -> ResolverViewInterface:
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
Also return metadata about the event (e.g., action type).
@@ -749,10 +813,17 @@ class GithubFactory:
user_id = payload['sender']['id']
username = payload['sender']['login']
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
if keyloak_user_id is None:
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
is_public_repo = not repo_obj.get('private', True)
user_info = UserData(
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
)
installation_id = message.message['installation']
@@ -776,7 +847,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
elif GithubFactory.is_issue_comment(message):
@@ -802,7 +872,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
elif GithubFactory.is_pr_comment(message):
@@ -813,12 +882,12 @@ class GithubFactory:
access_token = ''
with GithubIntegration(
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
) as integration:
access_token = integration.get_access_token(installation_id).token
head_ref = None
with Github(auth=Auth.Token(access_token)) as gh:
with Github(access_token) as gh:
repo = gh.get_repo(selected_repo)
pull_request = repo.get_pull(issue_number)
head_ref = pull_request.head.ref
@@ -844,7 +913,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
elif GithubFactory.is_inline_pr_comment(message):
@@ -878,7 +946,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
else:

View File

@@ -15,7 +15,6 @@ from integrations.utils import (
CONVERSATION_URL,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
@@ -25,11 +24,7 @@ from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.secrets import Secrets
@@ -254,13 +249,6 @@ class GitlabManager(Manager):
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(
f'[GitLab] Session expired for user {user_info.username}: {str(e)}'
)
msg_info = get_session_expired_message(user_info.username)
# Send the acknowledgment message
msg = self.create_outgoing_message(msg_info)
await self.send_message(msg, gitlab_view)

View File

@@ -1,7 +1,7 @@
import asyncio
from integrations.store_repo_utils import store_repositories_in_db
from integrations.types import GitLabResourceType
from integrations.utils import store_repositories_in_db
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
@@ -80,52 +80,22 @@ class SaaSGitLabService(GitLabService):
logger.warning('external_auth_token and user_id not set!')
return gitlab_token
async def get_owned_groups(self, min_access_level: int = 40) -> list[dict]:
async def get_owned_groups(self) -> list[dict]:
"""
Get all top-level groups where the current user has admin access.
This method supports pagination and fetches all groups where the user has
at least the specified access level.
Args:
min_access_level: Minimum access level required (default: 40 for Maintainer or Owner)
- 40: Maintainer or Owner
- 50: Owner only
Get all groups for which the current user is the owner.
Returns:
list[dict]: A list of groups where user has the specified access level or higher.
list[dict]: A list of groups owned by the current user.
"""
groups_with_admin_access = []
page = 1
per_page = 100
url = f'{self.BASE_URL}/groups'
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
while True:
try:
url = f'{self.BASE_URL}/groups'
params = {
'page': str(page),
'per_page': str(per_page),
'min_access_level': min_access_level,
'top_level_only': 'true',
}
response, headers = await self._make_request(url, params)
if not response:
break
groups_with_admin_access.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
except Exception:
logger.warning(f'Error fetching groups on page {page}', exc_info=True)
break
return groups_with_admin_access
try:
response, headers = await self._make_request(url, params)
return response
except Exception:
logger.warning('Error fetching owned groups', exc_info=True)
return []
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
"""
@@ -557,55 +527,3 @@ class SaaSGitLabService(GitLabService):
await self._make_request(url=url, params=params, method=RequestMethod.POST)
except Exception as e:
logger.exception(f'[GitLab]: Reply to MR failed {e}')
async def get_user_resources_with_admin_access(
self,
) -> tuple[list[dict], list[dict]]:
"""
Get all projects and groups where the current user has admin access (maintainer or owner).
Returns:
tuple[list[dict], list[dict]]: A tuple containing:
- list of projects where user has admin access
- list of groups where user has admin access
"""
projects_with_admin_access = []
groups_with_admin_access = []
# Fetch all projects the user is a member of
page = 1
per_page = 100
while True:
try:
url = f'{self.BASE_URL}/projects'
params = {
'page': str(page),
'per_page': str(per_page),
'membership': 1,
'min_access_level': 40, # Maintainer or Owner
}
response, headers = await self._make_request(url, params)
if not response:
break
projects_with_admin_access.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
except Exception:
logger.warning(f'Error fetching projects on page {page}', exc_info=True)
break
# Fetch all groups where user is owner or maintainer
groups_with_admin_access = await self.get_owned_groups(min_access_level=40)
logger.info(
f'Found {len(projects_with_admin_access)} projects and {len(groups_with_admin_access)} groups with admin access'
)
return projects_with_admin_access, groups_with_admin_access

View File

@@ -1,196 +0,0 @@
"""Shared utilities for GitLab webhook installation.
This module contains reusable functions and classes for installing GitLab webhooks
that can be used by both the cron job and API routes.
"""
from typing import cast
from uuid import uuid4
from integrations.types import GitLabResourceType
from integrations.utils import GITLAB_WEBHOOK_URL
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
from storage.gitlab_webhook_store import GitlabWebhookStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import GitService
# Webhook configuration constants
WEBHOOK_NAME = 'OpenHands Resolver'
SCOPES: list[str] = [
'note_events',
'merge_requests_events',
'confidential_issues_events',
'issues_events',
'confidential_note_events',
'job_events',
'pipeline_events',
]
class BreakLoopException(Exception):
"""Exception raised when webhook installation conditions are not met or rate limited."""
pass
async def verify_webhook_conditions(
gitlab_service: type[GitService],
resource_type: GitLabResourceType,
resource_id: str,
webhook_store: GitlabWebhookStore,
webhook: GitlabWebhook,
) -> None:
"""
Verify all conditions are met for webhook installation.
Raises BreakLoopException if any condition fails or rate limited.
Args:
gitlab_service: GitLab service instance
resource_type: Type of resource (PROJECT or GROUP)
resource_id: ID of the resource
webhook_store: Webhook store instance
webhook: Webhook object to verify
"""
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
# Check if resource exists
does_resource_exist, status = await gitlab_service.check_resource_exists(
resource_type, resource_id
)
logger.info(
'Does resource exists',
extra={
'does_resource_exist': does_resource_exist,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
if status == WebhookStatus.RATE_LIMITED:
raise BreakLoopException()
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
await webhook_store.delete_webhook(webhook)
raise BreakLoopException()
# Check if user has admin access
(
is_user_admin_of_resource,
status,
) = await gitlab_service.check_user_has_admin_access_to_resource(
resource_type, resource_id
)
logger.info(
'Is user admin',
extra={
'is_user_admin': is_user_admin_of_resource,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
if status == WebhookStatus.RATE_LIMITED:
raise BreakLoopException()
if not is_user_admin_of_resource:
await webhook_store.delete_webhook(webhook)
raise BreakLoopException()
# Check if webhook already exists
(
does_webhook_exist_on_resource,
status,
) = await gitlab_service.check_webhook_exists_on_resource(
resource_type, resource_id, GITLAB_WEBHOOK_URL
)
logger.info(
'Does webhook already exist',
extra={
'does_webhook_exist_on_resource': does_webhook_exist_on_resource,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
if status == WebhookStatus.RATE_LIMITED:
raise BreakLoopException()
if does_webhook_exist_on_resource != webhook.webhook_exists:
await webhook_store.update_webhook(
webhook, {'webhook_exists': does_webhook_exist_on_resource}
)
if does_webhook_exist_on_resource:
raise BreakLoopException()
async def install_webhook_on_resource(
gitlab_service: type[GitService],
resource_type: GitLabResourceType,
resource_id: str,
webhook_store: GitlabWebhookStore,
webhook: GitlabWebhook,
) -> tuple[str | None, WebhookStatus | None]:
"""
Install webhook on a GitLab resource.
Args:
gitlab_service: GitLab service instance
resource_type: Type of resource (PROJECT or GROUP)
resource_id: ID of the resource
webhook_store: Webhook store instance
webhook: Webhook object to install
Returns:
Tuple of (webhook_id, status)
"""
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
webhook_uuid = f'{str(uuid4())}'
webhook_id, status = await gitlab_service.install_webhook(
resource_type=resource_type,
resource_id=resource_id,
webhook_name=WEBHOOK_NAME,
webhook_url=GITLAB_WEBHOOK_URL,
webhook_secret=webhook_secret,
webhook_uuid=webhook_uuid,
scopes=SCOPES,
)
log_extra = {
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
}
if status == WebhookStatus.RATE_LIMITED:
logger.warning('Rate limited while creating webhook', extra=log_extra)
raise BreakLoopException()
if webhook_id:
await webhook_store.update_webhook(
webhook=webhook,
update_fields={
'webhook_secret': webhook_secret,
'webhook_exists': True, # webhook was created
'webhook_url': GITLAB_WEBHOOK_URL,
'scopes': SCOPES,
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
},
)
logger.info('Created new webhook', extra=log_extra)
else:
logger.error('Failed to create webhook', extra=log_extra)
return webhook_id, status

View File

@@ -1,38 +1,22 @@
"""Jira integration manager.
This module orchestrates the processing of Jira webhook events:
1. Parse webhook payload (via JiraPayloadParser)
2. Validate workspace
3. Authenticate user
4. Create view with repository selection (via JiraFactory)
5. Start conversation job
The manager delegates payload parsing to JiraPayloadParser and view creation
to JiraFactory, keeping the orchestration logic clean and traceable.
"""
import hashlib
import hmac
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
import httpx
from integrations.jira.jira_payload import (
JiraPayloadError,
JiraPayloadParser,
JiraPayloadSkipped,
JiraPayloadSuccess,
JiraWebhookPayload,
from fastapi import Request
from integrations.jira.jira_types import JiraViewInterface
from integrations.jira.jira_view import (
JiraExistingConversationView,
JiraFactory,
JiraNewConversationView,
)
from integrations.jira.jira_types import (
JiraViewInterface,
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
from integrations.manager import Manager
from integrations.models import Message
from integrations.models import JobContext, Message
from integrations.utils import (
HOST,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_oh_labels,
get_session_expired_message,
filter_potential_repos_by_user_msg,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
@@ -43,221 +27,312 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
# Get OH labels for this environment
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
class JiraManager(Manager):
"""Manager for processing Jira webhook events.
This class orchestrates the flow from webhook receipt to conversation creation,
delegating parsing to JiraPayloadParser and view creation to JiraFactory.
"""
def __init__(self, token_manager: TokenManager):
self.token_manager = token_manager
self.integration_store = JiraIntegrationStore.get_instance()
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'jira')
)
self.payload_parser = JiraPayloadParser(
oh_label=OH_LABEL,
inline_oh_label=INLINE_OH_LABEL,
)
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message.
Flow:
1. Parse webhook payload
2. Validate workspace exists and is active
3. Authenticate user
4. Create view (includes fetching issue details and selecting repository)
5. Start job
Each step has clear logging for traceability.
"""
raw_payload = message.message.get('payload', {})
# Step 1: Parse webhook payload
logger.info(
'[Jira] Received webhook',
extra={'raw_payload': raw_payload},
)
parse_result = self.payload_parser.parse(raw_payload)
if isinstance(parse_result, JiraPayloadSkipped):
logger.info(
'[Jira] Webhook skipped', extra={'reason': parse_result.skip_reason}
)
return
if isinstance(parse_result, JiraPayloadError):
logger.warning(
'[Jira] Webhook parse failed', extra={'error': parse_result.error}
)
return
payload = parse_result.payload
logger.info(
'[Jira] Processing webhook',
extra={
'event_type': payload.event_type.value,
'issue_key': payload.issue_key,
'user_email': payload.user_email,
},
)
# Step 2: Validate workspace
workspace = await self._get_active_workspace(payload)
if not workspace:
return
# Step 3: Authenticate user
jira_user, saas_user_auth = await self._authenticate_user(payload, workspace)
if not jira_user or not saas_user_auth:
return
# Step 4: Create view (includes issue details fetch and repo selection)
decrypted_api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
try:
view = await JiraFactory.create_view(
payload=payload,
workspace=workspace,
user=jira_user,
user_auth=saas_user_auth,
decrypted_api_key=decrypted_api_key,
)
except RepositoryNotFoundError as e:
logger.warning(
'[Jira] Repository not found',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
await self._send_error_from_payload(payload, workspace, str(e))
return
except StartingConvoException as e:
logger.warning(
'[Jira] View creation failed',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
await self._send_error_from_payload(payload, workspace, str(e))
return
except Exception as e:
logger.error(
'[Jira] Unexpected error creating view',
extra={'issue_key': payload.issue_key, 'error': str(e)},
exc_info=True,
)
await self._send_error_from_payload(
payload,
workspace,
'Failed to initialize conversation. Please try again.',
)
return
# Step 5: Start job
await self.start_job(view)
async def _get_active_workspace(
self, payload: JiraWebhookPayload
) -> JiraWorkspace | None:
"""Validate and return the workspace for the webhook.
Returns None if:
- Workspace not found
- Workspace is inactive
- Request is from service account (to prevent recursion)
"""
workspace = await self.integration_store.get_workspace_by_name(
payload.workspace_name
)
if not workspace:
logger.warning(
'[Jira] Workspace not found',
extra={'workspace_name': payload.workspace_name},
)
# Can't send error without workspace credentials
return None
# Prevent recursive triggers from service account
if payload.user_email == workspace.svc_acc_email:
logger.debug(
'[Jira] Ignoring service account trigger',
extra={'workspace_name': payload.workspace_name},
)
return None
if workspace.status != 'active':
logger.warning(
'[Jira] Workspace inactive',
extra={'workspace_id': workspace.id, 'status': workspace.status},
)
await self._send_error_from_payload(
payload, workspace, 'Jira integration is not active for your workspace.'
)
return None
return workspace
async def _authenticate_user(
self, payload: JiraWebhookPayload, workspace: JiraWorkspace
async def authenticate_user(
self, jira_user_id: str, workspace_id: int
) -> tuple[JiraUser | None, UserAuth | None]:
"""Authenticate the Jira user and get OpenHands auth."""
"""Authenticate Jira user and get their OpenHands user auth."""
# Find active Jira user by Keycloak user ID and workspace ID
jira_user = await self.integration_store.get_active_user(
payload.account_id, workspace.id
jira_user_id, workspace_id
)
if not jira_user:
logger.warning(
'[Jira] User not found or inactive',
extra={
'account_id': payload.account_id,
'user_email': payload.user_email,
'workspace_id': workspace.id,
},
)
await self._send_error_from_payload(
payload,
workspace,
f'User {payload.user_email} is not authenticated or active in the Jira integration.',
f'[Jira] No active Jira user found for {jira_user_id} in workspace {workspace_id}'
)
return None, None
saas_user_auth = await get_user_auth_from_keycloak_id(
jira_user.keycloak_user_id
)
if not saas_user_auth:
logger.warning(
'[Jira] Failed to get OpenHands auth',
extra={
'keycloak_user_id': jira_user.keycloak_user_id,
'user_email': payload.user_email,
},
)
await self._send_error_from_payload(
payload,
workspace,
f'User {payload.user_email} is not authenticated with OpenHands.',
)
return None, None
return jira_user, saas_user_auth
async def start_job(self, view: JiraViewInterface):
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
"""Get repositories that the user has access to."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return []
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
repos: list[Repository] = await client.get_repositories(
'pushed', server_config.app_mode, None, None, None, None
)
return repos
async def validate_request(
self, request: Request
) -> Tuple[bool, Optional[str], Optional[Dict]]:
"""Verify Jira webhook signature."""
signature_header = request.headers.get('x-hub-signature')
signature = signature_header.split('=')[1] if signature_header else None
body = await request.body()
payload = await request.json()
workspace_name = ''
if payload.get('webhookEvent') == 'comment_created':
selfUrl = payload.get('comment', {}).get('author', {}).get('self')
elif payload.get('webhookEvent') == 'jira:issue_updated':
selfUrl = payload.get('user', {}).get('self')
else:
workspace_name = ''
parsedUrl = urlparse(selfUrl)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not workspace_name:
logger.warning('[Jira] No workspace name found in webhook payload')
return False, None, None
if not signature:
logger.warning('[Jira] No signature found in webhook headers')
return False, None, None
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
if not workspace:
logger.warning('[Jira] Could not identify workspace for webhook')
return False, None, None
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
return False, None, None
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, digest):
logger.info('[Jira] Webhook signature verified successfully')
return True, signature, payload
return False, None, None
def parse_webhook(self, payload: Dict) -> JobContext | None:
event_type = payload.get('webhookEvent')
if event_type == 'comment_created':
comment_data = payload.get('comment', {})
comment = comment_data.get('body', '')
if '@openhands' not in comment:
return None
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
base_api_url = issue_data.get('self', '').split('/rest/')[0]
user_data = comment_data.get('author', {})
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
elif event_type == 'jira:issue_updated':
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if 'openhands' not in labels:
return None
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
base_api_url = issue_data.get('self', '').split('/rest/')[0]
user_data = payload.get('user', {})
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
comment = ''
else:
return None
workspace_name = ''
parsedUrl = urlparse(base_api_url)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not all(
[
issue_id,
issue_key,
user_email,
display_name,
account_id,
workspace_name,
base_api_url,
]
):
return None
return JobContext(
issue_id=issue_id,
issue_key=issue_key,
user_msg=comment,
user_email=user_email,
display_name=display_name,
platform_user_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
)
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message."""
payload = message.message.get('payload', {})
job_context = self.parse_webhook(payload)
if not job_context:
logger.info('[Jira] Webhook does not match trigger conditions')
return
# Get workspace by user email domain
workspace = await self.integration_store.get_workspace_by_name(
job_context.workspace_name
)
if not workspace:
logger.warning(
f'[Jira] No workspace found for email domain: {job_context.user_email}'
)
await self._send_error_comment(
job_context,
'Your workspace is not configured with Jira integration.',
None,
)
return
# Prevent any recursive triggers from the service account
if job_context.user_email == workspace.svc_acc_email:
return
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
await self._send_error_comment(
job_context,
'Jira integration is not active for your workspace.',
workspace,
)
return
# Authenticate user
jira_user, saas_user_auth = await self.authenticate_user(
job_context.platform_user_id, workspace.id
)
if not jira_user or not saas_user_auth:
logger.warning(
f'[Jira] User authentication failed for {job_context.user_email}'
)
await self._send_error_comment(
job_context,
f'User {job_context.user_email} is not authenticated or active in the Jira integration.',
workspace,
)
return
# Get issue details
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
issue_title, issue_description = await self.get_issue_details(
job_context, workspace.jira_cloud_id, workspace.svc_acc_email, api_key
)
job_context.issue_title = issue_title
job_context.issue_description = issue_description
except Exception as e:
logger.error(f'[Jira] Failed to get issue context: {str(e)}')
await self._send_error_comment(
job_context,
'Failed to retrieve issue details. Please check the issue key and try again.',
workspace,
)
return
try:
# Create Jira view
jira_view = await JiraFactory.create_jira_view_from_payload(
job_context,
saas_user_auth,
jira_user,
workspace,
)
except Exception as e:
logger.error(f'[Jira] Failed to create jira view: {str(e)}', exc_info=True)
await self._send_error_comment(
job_context,
'Failed to initialize conversation. Please try again.',
workspace,
)
return
if not await self.is_job_requested(message, jira_view):
return
await self.start_job(jira_view)
async def is_job_requested(
self, message: Message, jira_view: JiraViewInterface
) -> bool:
"""
Check if a job is requested and handle repository selection.
"""
if isinstance(jira_view, JiraExistingConversationView):
return True
try:
# Get user repositories
user_repos: list[Repository] = await self._get_repositories(
jira_view.saas_user_auth
)
target_str = f'{jira_view.job_context.issue_description}\n{jira_view.job_context.user_msg}'
# Try to infer repository from issue description
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
if match:
# Found exact repository match
jira_view.selected_repo = repos[0].full_name
logger.info(f'[Jira] Inferred repository: {repos[0].full_name}')
return True
else:
# No clear match - send repository selection comment
await self._send_repo_selection_comment(jira_view)
return False
except Exception as e:
logger.error(f'[Jira] Error in is_job_requested: {str(e)}')
return False
async def start_job(self, jira_view: JiraViewInterface):
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
@@ -265,79 +340,97 @@ class JiraManager(Manager):
)
try:
user_info: JiraUser = jira_view.jira_user
logger.info(
'[Jira] Starting job',
extra={
'issue_key': view.payload.issue_key,
'user_id': view.jira_user.keycloak_user_id,
'selected_repo': view.selected_repo,
},
f'[Jira] Starting job for user {user_info.keycloak_user_id} '
f'issue {jira_view.job_context.issue_key}',
)
# Create conversation
conversation_id = await view.create_or_update_conversation(self.jinja_env)
conversation_id = await jira_view.create_or_update_conversation(
self.jinja_env
)
logger.info(
'[Jira] Conversation created',
extra={
'conversation_id': conversation_id,
'issue_key': view.payload.issue_key,
},
f'[Jira] Created/Updated conversation {conversation_id} for issue {jira_view.job_context.issue_key}'
)
# Register callback processor for updates
if isinstance(view, JiraNewConversationView):
if isinstance(jira_view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=view.payload.issue_key,
workspace_name=view.jira_workspace.name,
)
register_callback_processor(conversation_id, processor)
logger.info(
'[Jira] Callback processor registered',
extra={'conversation_id': conversation_id},
issue_key=jira_view.job_context.issue_key,
workspace_name=jira_view.jira_workspace.name,
)
# Send success response
msg_info = view.get_response_msg()
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Jira] Created callback processor for conversation {conversation_id}'
)
# Send initial response
msg_info = jira_view.get_response_msg()
except MissingSettingsError as e:
logger.warning(
'[Jira] Missing settings error',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
logger.warning(f'[Jira] Missing settings error: {str(e)}')
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(
'[Jira] LLM authentication error',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(
'[Jira] Session expired',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = get_session_expired_message()
except StartingConvoException as e:
logger.warning(
'[Jira] Conversation start failed',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = str(e)
except Exception as e:
logger.error(
'[Jira] Unexpected error starting job',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
exc_info=True,
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
)
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
# Send response comment
await self._send_comment(view, msg_info)
try:
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg_info),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send response message: {str(e)}')
async def get_issue_details(
self,
job_context: JobContext,
jira_cloud_id: str,
svc_acc_email: str,
svc_acc_api_key: str,
) -> Tuple[str, str]:
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise ValueError(f'Issue with key {job_context.issue_key} not found.')
title = issue_payload.get('fields', {}).get('summary', '')
description = issue_payload.get('fields', {}).get('description', '')
if not title:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a title.'
)
if not description:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a description.'
)
return title, description
async def send_message(
self,
@@ -347,7 +440,6 @@ class JiraManager(Manager):
svc_acc_email: str,
svc_acc_api_key: str,
):
"""Send a comment to a Jira issue."""
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
@@ -359,53 +451,54 @@ class JiraManager(Manager):
response.raise_for_status()
return response.json()
async def _send_comment(self, view: JiraViewInterface, msg: str):
"""Send a comment using credentials from the view."""
try:
api_key = self.token_manager.decrypt_text(
view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg),
issue_key=view.payload.issue_key,
jira_cloud_id=view.jira_workspace.jira_cloud_id,
svc_acc_email=view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(
'[Jira] Failed to send comment',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
async def _send_error_from_payload(
async def _send_error_comment(
self,
payload: JiraWebhookPayload,
workspace: JiraWorkspace,
job_context: JobContext,
error_msg: str,
workspace: JiraWorkspace | None,
):
"""Send error comment before view is created (using payload directly)."""
"""Send error comment to Jira issue."""
if not workspace:
logger.error('[Jira] Cannot send error comment - no workspace available')
return
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg),
issue_key=payload.issue_key,
issue_key=job_context.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(
'[Jira] Failed to send error comment',
extra={'issue_key': payload.issue_key, 'error': str(e)},
logger.error(f'[Jira] Failed to send error comment: {str(e)}')
async def _send_repo_selection_comment(self, jira_view: JiraViewInterface):
"""Send a comment with repository options for the user to choose."""
try:
comment_msg = (
'I need to know which repository to work with. '
'Please add it to your issue description or send a followup comment.'
)
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
"""Extract workspace name from Jira webhook payload.
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
This method is used by the route for signature verification.
"""
parse_result = self.payload_parser.parse(payload)
if isinstance(parse_result, JiraPayloadSuccess):
return parse_result.payload.workspace_name
return None
await self.send_message(
self.create_outgoing_message(msg=comment_msg),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
logger.info(
f'[Jira] Sent repository selection comment for issue {jira_view.job_context.issue_key}'
)
except Exception as e:
logger.error(
f'[Jira] Failed to send repository selection comment: {str(e)}'
)

View File

@@ -1,267 +0,0 @@
"""Centralized payload parsing for Jira webhooks.
This module provides a single source of truth for parsing and validating
Jira webhook payloads, replacing scattered parsing logic throughout the codebase.
"""
from dataclasses import dataclass
from enum import Enum
from urllib.parse import urlparse
from openhands.core.logger import openhands_logger as logger
class JiraEventType(Enum):
"""Types of Jira events we handle."""
LABELED_TICKET = 'labeled_ticket'
COMMENT_MENTION = 'comment_mention'
@dataclass(frozen=True)
class JiraWebhookPayload:
"""Normalized, validated representation of a Jira webhook payload.
This immutable dataclass replaces JobContext and provides a single
source of truth for all webhook data. All parsing happens in
JiraPayloadParser, ensuring consistent validation.
"""
event_type: JiraEventType
raw_event: str # Original webhookEvent value
# Issue data
issue_id: str
issue_key: str
# User data
user_email: str
display_name: str
account_id: str
# Workspace data (derived from issue self URL)
workspace_name: str
base_api_url: str
# Event-specific data
comment_body: str = '' # For comment events
@property
def user_msg(self) -> str:
"""Alias for comment_body for backward compatibility."""
return self.comment_body
class JiraPayloadParseError(Exception):
"""Raised when payload parsing fails."""
def __init__(self, reason: str, event_type: str | None = None):
self.reason = reason
self.event_type = event_type
super().__init__(reason)
@dataclass(frozen=True)
class JiraPayloadSuccess:
"""Result when parsing succeeds."""
payload: JiraWebhookPayload
@dataclass(frozen=True)
class JiraPayloadSkipped:
"""Result when event is intentionally skipped."""
skip_reason: str
@dataclass(frozen=True)
class JiraPayloadError:
"""Result when parsing fails due to invalid data."""
error: str
JiraPayloadParseResult = JiraPayloadSuccess | JiraPayloadSkipped | JiraPayloadError
class JiraPayloadParser:
"""Centralized parser for Jira webhook payloads.
This class provides a single entry point for parsing webhooks,
determining event types, and extracting all necessary fields.
Replaces scattered parsing in JiraFactory and JiraManager.
"""
def __init__(self, oh_label: str, inline_oh_label: str):
"""Initialize parser with OpenHands label configuration.
Args:
oh_label: Label that triggers OpenHands (e.g., 'openhands')
inline_oh_label: Mention that triggers OpenHands (e.g., '@openhands')
"""
self.oh_label = oh_label
self.inline_oh_label = inline_oh_label
def parse(self, raw_payload: dict) -> JiraPayloadParseResult:
"""Parse a raw webhook payload into a normalized JiraWebhookPayload.
Args:
raw_payload: The raw webhook payload dict from Jira
Returns:
One of:
- JiraPayloadSuccess: Valid, actionable event with payload
- JiraPayloadSkipped: Event we intentionally don't process
- JiraPayloadError: Malformed payload we expected to process
"""
webhook_event = raw_payload.get('webhookEvent', '')
logger.debug(
'[Jira] Parsing webhook payload', extra={'webhook_event': webhook_event}
)
if webhook_event == 'jira:issue_updated':
return self._parse_label_event(raw_payload, webhook_event)
elif webhook_event == 'comment_created':
return self._parse_comment_event(raw_payload, webhook_event)
else:
return JiraPayloadSkipped(f'Unhandled webhook event type: {webhook_event}')
def _parse_label_event(
self, payload: dict, webhook_event: str
) -> JiraPayloadParseResult:
"""Parse an issue_updated event for label changes."""
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
# Extract labels that were added
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if self.oh_label not in labels:
return JiraPayloadSkipped(
f"Label event does not contain '{self.oh_label}' label"
)
# For label events, user data comes from 'user' field
user_data = payload.get('user', {})
return self._extract_and_validate(
payload=payload,
user_data=user_data,
event_type=JiraEventType.LABELED_TICKET,
webhook_event=webhook_event,
comment_body='',
)
def _parse_comment_event(
self, payload: dict, webhook_event: str
) -> JiraPayloadParseResult:
"""Parse a comment_created event."""
comment_data = payload.get('comment', {})
comment_body = comment_data.get('body', '')
if not self._has_mention(comment_body):
return JiraPayloadSkipped(
f"Comment does not mention '{self.inline_oh_label}'"
)
# For comment events, user data comes from 'comment.author'
user_data = comment_data.get('author', {})
return self._extract_and_validate(
payload=payload,
user_data=user_data,
event_type=JiraEventType.COMMENT_MENTION,
webhook_event=webhook_event,
comment_body=comment_body,
)
def _has_mention(self, text: str) -> bool:
"""Check if text contains an exact mention of OpenHands."""
from integrations.utils import has_exact_mention
return has_exact_mention(text, self.inline_oh_label)
def _extract_and_validate(
self,
payload: dict,
user_data: dict,
event_type: JiraEventType,
webhook_event: str,
comment_body: str,
) -> JiraPayloadParseResult:
"""Extract common fields and validate required data is present."""
issue_data = payload.get('issue', {})
# Extract all fields with empty string defaults (makes them str type)
issue_id = issue_data.get('id', '')
issue_key = issue_data.get('key', '')
user_email = user_data.get('emailAddress', '')
display_name = user_data.get('displayName', '')
account_id = user_data.get('accountId', '')
base_api_url, workspace_name = self._extract_workspace_from_url(
issue_data.get('self', '')
)
# Validate required fields
missing: list[str] = []
if not issue_id:
missing.append('issue.id')
if not issue_key:
missing.append('issue.key')
if not user_email:
missing.append('user.emailAddress')
if not display_name:
missing.append('user.displayName')
if not account_id:
missing.append('user.accountId')
if not workspace_name:
missing.append('workspace_name (derived from issue.self)')
if not base_api_url:
missing.append('base_api_url (derived from issue.self)')
if missing:
return JiraPayloadError(f"Missing required fields: {', '.join(missing)}")
return JiraPayloadSuccess(
JiraWebhookPayload(
event_type=event_type,
raw_event=webhook_event,
issue_id=issue_id,
issue_key=issue_key,
user_email=user_email,
display_name=display_name,
account_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
comment_body=comment_body,
)
)
def _extract_workspace_from_url(self, self_url: str) -> tuple[str, str]:
"""Extract base API URL and workspace name from issue self URL.
Args:
self_url: The 'self' URL from the issue data
Returns:
Tuple of (base_api_url, workspace_name)
"""
if not self_url:
return '', ''
# Extract base URL (everything before /rest/)
if '/rest/' in self_url:
base_api_url = self_url.split('/rest/')[0]
else:
parsed = urlparse(self_url)
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
# Extract workspace name (hostname)
parsed = urlparse(base_api_url)
workspace_name = parsed.hostname or ''
return base_api_url, workspace_name

View File

@@ -1,42 +1,26 @@
"""Type definitions and interfaces for Jira integration."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from integrations.models import JobContext
from jinja2 import Environment
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.server.user_auth.user_auth import UserAuth
if TYPE_CHECKING:
from integrations.jira.jira_payload import JiraWebhookPayload
class JiraViewInterface(ABC):
"""Interface for Jira views that handle different types of Jira interactions.
"""Interface for Jira views that handle different types of Jira interactions."""
Views hold the webhook payload directly rather than duplicating fields,
and fetch issue details lazily when needed.
"""
# Core data - view holds these references
payload: 'JiraWebhookPayload'
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
# Mutable state set during processing
selected_repo: str | None
conversation_id: str
@abstractmethod
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch and cache issue title and description from Jira API.
Returns:
Tuple of (issue_title, issue_description)
"""
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
pass
@abstractmethod
@@ -51,21 +35,6 @@ class JiraViewInterface(ABC):
class StartingConvoException(Exception):
"""Exception raised when starting a conversation fails.
This provides user-friendly error messages that can be sent back to Jira.
"""
pass
class RepositoryNotFoundError(Exception):
"""Raised when a repository cannot be determined from the issue.
This is a separate error domain from StartingConvoException - it represents
a precondition failure (no repo configured/found) rather than a conversation
creation failure. The manager catches this and converts it to a user-friendly
message.
"""
"""Exception raised when starting a conversation fails."""
pass

View File

@@ -1,21 +1,8 @@
"""Jira view implementations and factory.
from dataclasses import dataclass
Views are responsible for:
- Holding the webhook payload and auth context
- Lazy-loading issue details from Jira API when needed
- Creating conversations with the selected repository
"""
from dataclasses import dataclass, field
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
from integrations.jira.jira_types import (
JiraViewInterface,
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from integrations.jira.jira_types import JiraViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
@@ -23,147 +10,55 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
integration_store = JiraIntegrationStore.get_instance()
@dataclass
class JiraNewConversationView(JiraViewInterface):
"""View for creating a new Jira conversation.
This view holds the webhook payload directly and lazily fetches
issue details when needed for rendering templates.
"""
payload: JiraWebhookPayload
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None = None
conversation_id: str = ''
selected_repo: str | None
conversation_id: str
# Lazy-loaded issue details (cached after first fetch)
_issue_title: str | None = field(default=None, repr=False)
_issue_description: str | None = field(default=None, repr=False)
# Decrypted API key (set by factory)
_decrypted_api_key: str = field(default='', repr=False)
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch issue details from Jira API (cached after first call).
Returns:
Tuple of (issue_title, issue_description)
Raises:
StartingConvoException: If issue details cannot be fetched
"""
if self._issue_title is not None and self._issue_description is not None:
return self._issue_title, self._issue_description
try:
url = f'{JIRA_CLOUD_API_URL}/{self.jira_workspace.jira_cloud_id}/rest/api/2/issue/{self.payload.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.get(
url,
auth=(
self.jira_workspace.svc_acc_email,
self._decrypted_api_key,
),
)
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise StartingConvoException(
f'Issue {self.payload.issue_key} not found.'
)
self._issue_title = issue_payload.get('fields', {}).get('summary', '')
self._issue_description = (
issue_payload.get('fields', {}).get('description', '') or ''
)
if not self._issue_title:
raise StartingConvoException(
f'Issue {self.payload.issue_key} does not have a title.'
)
logger.info(
'[Jira] Fetched issue details',
extra={
'issue_key': self.payload.issue_key,
'has_description': bool(self._issue_description),
},
)
return self._issue_title, self._issue_description
except httpx.HTTPStatusError as e:
logger.error(
'[Jira] Failed to fetch issue details',
extra={
'issue_key': self.payload.issue_key,
'status': e.response.status_code,
},
)
raise StartingConvoException(
f'Failed to fetch issue details: HTTP {e.response.status_code}'
)
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to fetch issue details',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
)
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get instructions for the conversation.
This fetches issue details if not already cached.
Returns:
Tuple of (system_instructions, user_message)
"""
issue_title, issue_description = await self.get_issue_details()
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('jira_instructions.j2')
instructions = instructions_template.render()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.payload.issue_key,
issue_title=issue_title,
issue_description=issue_description,
user_message=self.payload.user_msg,
issue_key=self.job_context.issue_key,
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
user_message=self.job_context.user_msg or '',
)
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Jira conversation.
"""Create a new Jira conversation"""
Returns:
The conversation ID
Raises:
StartingConvoException: If conversation creation fails
"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
instructions, user_msg = self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
@@ -181,259 +76,149 @@ class JiraNewConversationView(JiraViewInterface):
self.conversation_id = agent_loop_info.conversation_id
logger.info(
'[Jira] Created conversation',
extra={
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
},
)
logger.info(f'[Jira] Created conversation {self.conversation_id}')
# Store Jira conversation mapping
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
return self.conversation_id
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to create conversation',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
exc_info=True,
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira."""
"""Get the response message to send back to Jira"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.payload.display_name} can [track my progress here|{conversation_link}]."
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
@dataclass
class JiraExistingConversationView(JiraViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('jira_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
user_message=self.job_context.user_msg or '',
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
)
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Update an existing Jira conversation"""
user_id = self.jira_user.keycloak_user_id
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
# Should we raise here if there are no providers?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
class JiraFactory:
"""Factory for creating Jira views.
The factory is responsible for:
- Creating the appropriate view type
- Inferring and selecting the repository
- Validating all required data is available
Repository selection happens here so that view creation either
succeeds with a valid repo or fails with a clear error.
"""
"""Factory for creating Jira views based on message content"""
@staticmethod
async def _create_provider_handler(user_auth: UserAuth) -> ProviderHandler | None:
"""Create a ProviderHandler for the user."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return None
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
return ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
@staticmethod
def _extract_potential_repos(
issue_key: str,
issue_title: str,
issue_description: str,
user_msg: str,
) -> list[str]:
"""Extract potential repository names from issue content.
Raises:
RepositoryNotFoundError: If no potential repos found in text.
"""
search_text = f'{issue_title}\n{issue_description}\n{user_msg}'
potential_repos = infer_repo_from_message(search_text)
if not potential_repos:
raise RepositoryNotFoundError(
'Could not determine which repository to use. '
'Please mention the repository (e.g., owner/repo) in the issue description or comment.'
)
logger.info(
'[Jira] Found potential repositories in issue content',
extra={'issue_key': issue_key, 'potential_repos': potential_repos},
)
return potential_repos
@staticmethod
async def _verify_repos(
issue_key: str,
potential_repos: list[str],
provider_handler: ProviderHandler,
) -> list[str]:
"""Verify which repos the user has access to."""
verified_repos: list[str] = []
for repo_name in potential_repos:
try:
repository = await provider_handler.verify_repo_provider(repo_name)
verified_repos.append(repository.full_name)
logger.debug(
'[Jira] Repository verification succeeded',
extra={'issue_key': issue_key, 'repository': repository.full_name},
)
except Exception as e:
logger.debug(
'[Jira] Repository verification failed',
extra={
'issue_key': issue_key,
'repo_name': repo_name,
'error': str(e),
},
)
return verified_repos
@staticmethod
def _select_single_repo(
issue_key: str,
potential_repos: list[str],
verified_repos: list[str],
) -> str:
"""Select exactly one repo from verified repos.
Raises:
RepositoryNotFoundError: If zero or multiple repos verified.
"""
if len(verified_repos) == 0:
raise RepositoryNotFoundError(
f'Could not access any of the mentioned repositories: {", ".join(potential_repos)}. '
'Please ensure you have access to the repository and it exists.'
)
if len(verified_repos) > 1:
raise RepositoryNotFoundError(
f'Multiple repositories found: {", ".join(verified_repos)}. '
'Please specify exactly one repository in the issue description or comment.'
)
logger.info(
'[Jira] Verified repository access',
extra={'issue_key': issue_key, 'repository': verified_repos[0]},
)
return verified_repos[0]
@staticmethod
async def _infer_repository(
payload: JiraWebhookPayload,
user_auth: UserAuth,
issue_title: str,
issue_description: str,
) -> str:
"""Infer and verify the repository from issue content.
Raises:
RepositoryNotFoundError: If no valid repository can be determined.
"""
provider_handler = await JiraFactory._create_provider_handler(user_auth)
if not provider_handler:
raise RepositoryNotFoundError(
'No Git provider connected. Please connect a Git provider in OpenHands settings.'
)
potential_repos = JiraFactory._extract_potential_repos(
payload.issue_key, issue_title, issue_description, payload.user_msg
)
verified_repos = await JiraFactory._verify_repos(
payload.issue_key, potential_repos, provider_handler
)
return JiraFactory._select_single_repo(
payload.issue_key, potential_repos, verified_repos
)
@staticmethod
async def create_view(
payload: JiraWebhookPayload,
workspace: JiraWorkspace,
user: JiraUser,
user_auth: UserAuth,
decrypted_api_key: str,
async def create_jira_view_from_payload(
job_context: JobContext,
saas_user_auth: UserAuth,
jira_user: JiraUser,
jira_workspace: JiraWorkspace,
) -> JiraViewInterface:
"""Create a Jira view with repository already selected.
"""Create appropriate Jira view based on the message and user state"""
This factory method:
1. Creates the view with payload and auth context
2. Fetches issue details (needed for repo inference)
3. Infers and selects the repository
if not jira_user or not saas_user_auth or not jira_workspace:
raise StartingConvoException('User not authenticated with Jira integration')
If any step fails, an appropriate exception is raised with
a user-friendly message.
Args:
payload: Parsed webhook payload
workspace: The Jira workspace
user: The Jira user
user_auth: OpenHands user authentication
decrypted_api_key: Decrypted service account API key
Returns:
A JiraViewInterface with selected_repo populated
Raises:
StartingConvoException: If view creation fails
RepositoryNotFoundError: If repository cannot be determined
"""
logger.info(
'[Jira] Creating view',
extra={
'issue_key': payload.issue_key,
'event_type': payload.event_type.value,
},
conversation = await integration_store.get_user_conversations_by_issue_id(
job_context.issue_id, jira_user.id
)
# Create the view
view = JiraNewConversationView(
payload=payload,
saas_user_auth=user_auth,
jira_user=user,
jira_workspace=workspace,
_decrypted_api_key=decrypted_api_key,
if conversation:
logger.info(
f'[Jira] Found existing conversation for issue {job_context.issue_id}'
)
return JiraExistingConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None,
conversation_id=conversation.conversation_id,
)
return JiraNewConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None, # Will be set later after repo inference
conversation_id='', # Will be set when conversation is created
)
# Fetch issue details (needed for repo inference)
try:
issue_title, issue_description = await view.get_issue_details()
except StartingConvoException:
raise # Re-raise with original message
except Exception as e:
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
# Infer and select repository
selected_repo = await JiraFactory._infer_repository(
payload=payload,
user_auth=user_auth,
issue_title=issue_title,
issue_description=issue_description,
)
view.selected_repo = selected_repo
logger.info(
'[Jira] View created successfully',
extra={
'issue_key': payload.issue_key,
'selected_repo': selected_repo,
},
)
return view

View File

@@ -19,7 +19,6 @@ from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
@@ -33,11 +32,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
@@ -402,10 +397,6 @@ class JiraDcManager(Manager):
logger.warning(f'[Jira DC] LLM authentication error: {str(e)}')
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(f'[Jira DC] Session expired: {str(e)}')
msg_info = get_session_expired_message()
except Exception as e:
logger.error(
f'[Jira DC] Unexpected error starting job: {str(e)}', exc_info=True

View File

@@ -16,7 +16,6 @@ from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
@@ -30,11 +29,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
@@ -392,10 +387,6 @@ class LinearManager(Manager):
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(f'[Linear] Session expired: {str(e)}')
msg_info = get_session_expired_message()
except Exception as e:
logger.error(
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True

View File

@@ -16,6 +16,11 @@ class Manager(ABC):
"Send message to integration from Openhands server"
raise NotImplementedError
@abstractmethod
async def is_job_requested(self, message: Message) -> bool:
"Confirm that a job is being requested"
raise NotImplementedError
@abstractmethod
def start_job(self):
"Kick off a job with openhands agent"

View File

@@ -1,81 +0,0 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
class ResolverUserContext(UserContext):
"""User context for resolver operations that inherits from UserContext."""
def __init__(
self,
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
async def get_user_info(self) -> UserInfo:
user_settings = await self.saas_user_auth.get_user_settings()
user_id = await self.saas_user_auth.get_user_id()
if user_settings:
return UserInfo(
id=user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
return UserInfo(id=user_id)
async def _get_provider_handler(self) -> ProviderHandler:
"""Get or create a ProviderHandler for git operations."""
if self._provider_handler is None:
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('No provider tokens available')
user_id = await self.saas_user_auth.get_user_id()
self._provider_handler = ProviderHandler(
provider_tokens=provider_tokens, external_auth_id=user_id
)
return self._provider_handler
async def get_authenticated_git_url(
self, repository: str, is_optional: bool = False
) -> str:
provider_handler = await self._get_provider_handler()
url = await provider_handler.get_authenticated_git_url(
repository, is_optional=is_optional
)
return url
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token string from git_provider_tokens
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens:
provider_token = provider_tokens.get(provider_type)
if provider_token and provider_token.token:
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:
"""Get secrets for the user, including custom secrets."""
secrets = await self.saas_user_auth.get_secrets()
if secrets:
# Convert custom secrets to StaticSecret objects for SDK compatibility
# secrets.custom_secrets is of type Mapping[str, CustomSecret]
converted_secrets = {}
for key, custom_secret in secrets.custom_secrets.items():
# Extract the secret value from CustomSecret and convert to StaticSecret
secret_value = custom_secret.secret.get_secret_value()
converted_secrets[key] = StaticSecret(value=secret_value)
return converted_secrets
return {}
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()

View File

@@ -14,10 +14,10 @@ from integrations.slack.slack_view import (
from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import SLACK_CLIENT_ID
from server.utils.conversation_callback_utils import register_callback_processor
from slack_sdk.oauth import AuthorizeUrlGenerator
@@ -29,11 +29,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import config, server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
authorize_url_generator = AuthorizeUrlGenerator(
@@ -58,6 +54,17 @@ class SlackManager(Manager):
if message.source != SourceType.SLACK:
raise ValueError(f'Unexpected message source {message.source}')
async def _get_user_auth(self, keycloak_user_id: str) -> UserAuth:
offline_token = await self.token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth
async def authenticate_user(
self, slack_user_id: str
) -> tuple[SlackUser | None, UserAuth | None]:
@@ -74,9 +81,7 @@ class SlackManager(Manager):
saas_user_auth = None
if slack_user:
saas_user_auth = await get_saas_user_auth(
slack_user.keycloak_user_id, self.token_manager
)
saas_user_auth = await self._get_user_auth(slack_user.keycloak_user_id)
# slack_view.saas_user_auth = await self._get_user_auth(slack_view.slack_to_openhands_user.keycloak_user_id)
return slack_user, saas_user_auth
@@ -239,11 +244,13 @@ class SlackManager(Manager):
async def is_job_requested(
self, message: Message, slack_view: SlackViewInterface
) -> bool:
"""A job is always request we only receive webhooks for events associated with the slack bot
"""
A job is always request we only receive webhooks for events associated with the slack bot
This method really just checks
1. Is the user is authenticated
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
"""
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
if isinstance(slack_view, SlackUpdateExistingConversationView):
return True
@@ -310,15 +317,10 @@ class SlackManager(Manager):
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
)
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
if (
not isinstance(slack_view, SlackUpdateExistingConversationView)
and not slack_view.v1_enabled
):
if not isinstance(slack_view, SlackUpdateExistingConversationView):
# We don't re-subscribe for follow up messages from slack.
# Summaries are generated for every messages anyways, we only need to do
# this subscription once for the event which kicked off the job.
processor = SlackCallbackProcessor(
slack_user_id=slack_view.slack_user_id,
channel_id=slack_view.channel_id,
@@ -333,14 +335,6 @@ class SlackManager(Manager):
logger.info(
f'[Slack] Created callback processor for conversation {conversation_id}'
)
elif isinstance(slack_view, SlackUpdateExistingConversationView):
logger.info(
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
)
elif slack_view.v1_enabled:
logger.info(
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
)
msg_info = slack_view.get_response_msg()
@@ -358,13 +352,6 @@ class SlackManager(Manager):
msg_info = f'@{user_info.slack_display_name} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(
f'[Slack] Session expired for user {user_info.slack_display_name}: {str(e)}'
)
msg_info = get_session_expired_message(user_info.slack_display_name)
except StartingConvoException as e:
msg_info = str(e)

View File

@@ -21,16 +21,20 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
"Instructions passed when conversation is first initialized"
pass
@abstractmethod
async def create_or_update_conversation(self, jinja_env: Environment):
"""Create a new conversation"""
"Create a new conversation"
pass
@abstractmethod
def get_callback_id(self) -> str:
"Unique callback id for subscribription made to EventStream for fetching agent summary"
pass
@abstractmethod
@@ -39,4 +43,6 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
class StartingConvoException(Exception):
"""Raised when trying to send message to a conversation that's is still starting up"""
"""
Raised when trying to send message to a conversation that's is still starting up
"""

View File

@@ -1,273 +0,0 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import CONVERSATION_URL, get_summary_instruction
from pydantic import Field
from slack_sdk import WebClient
from storage.slack_team_store import SlackTeamStore
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
_logger = logging.getLogger(__name__)
class SlackV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Slack V1 integrations."""
slack_view_data: dict[str, str | None] = Field(default_factory=dict)
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
_logger.info('[Slack V1] Callback agent state was %s', event)
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception('[Slack V1] Error processing callback: %s', e)
# Only try to post error to Slack if we have basic requirements
try:
await self._post_summary_to_slack(
f'OpenHands encountered an error: **{str(e)}**.\n\n'
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
'for more information.'
)
except Exception as post_error:
_logger.warning(
'[Slack V1] Failed to post error message to Slack: %s', post_error
)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
# -------------------------------------------------------------------------
# Slack helpers
# -------------------------------------------------------------------------
def _get_bot_access_token(self):
slack_team_store = SlackTeamStore.get_instance()
bot_access_token = slack_team_store.get_team_bot_token(
self.slack_view_data['team_id']
)
return bot_access_token
async def _post_summary_to_slack(self, summary: str) -> None:
"""Post a summary message to the configured Slack channel."""
bot_access_token = self._get_bot_access_token()
if not bot_access_token:
raise RuntimeError('Missing Slack bot access token')
channel_id = self.slack_view_data['channel_id']
thread_ts = self.slack_view_data.get('thread_ts') or self.slack_view_data.get(
'message_ts'
)
client = WebClient(token=bot_access_token)
try:
# Post the summary as a threaded reply
response = client.chat_postMessage(
channel=channel_id,
text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,
)
if not response['ok']:
raise RuntimeError(
f"Slack API error: {response.get('error', 'Unknown error')}"
)
_logger.info(
'[Slack V1] Successfully posted summary to channel %s', channel_id
)
except Exception as e:
_logger.error('[Slack V1] Failed to post message to Slack: %s', e)
raise
# -------------------------------------------------------------------------
# Agent / sandbox helpers
# -------------------------------------------------------------------------
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f'{agent_server_url.rstrip("/")}'
f'/api/conversations/{conversation_id}/ask_agent'
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception: # noqa: BLE001
pass
_logger.error(
'[Slack V1] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
exc_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.error(
'[Slack V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
except httpx.RequestError as e:
error_detail = f'Request error to {url}: {str(e)}'
_logger.error(
'[Slack V1] %s. Request payload: %s',
error_detail,
payload,
exc_info=True,
)
raise Exception(error_detail)
# -------------------------------------------------------------------------
# Summary orchestration
# -------------------------------------------------------------------------
async def _request_summary(self, conversation_id: UUID) -> str:
"""
Ask the agent to produce a summary of its work and return the agent response.
NOTE: This method now returns a string (the agent server's response text)
and raises exceptions on errors. The wrapping into EventCallbackResult
is handled by __call__.
"""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)

View File

@@ -1,16 +1,8 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
from integrations.utils import (
CONVERSATION_URL,
ENABLE_V1_SLACK_RESOLVER,
get_final_agent_observation,
get_user_v1_enabled_setting,
)
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from slack_sdk import WebClient
from storage.slack_conversation import SlackConversation
@@ -18,34 +10,22 @@ from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
from storage.slack_user import SlackUser
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
SendMessageRequest,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
)
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
# =================================================
# SECTION: Slack view types
# SECTION: Github view types
# =================================================
@@ -54,10 +34,6 @@ slack_conversation_store = SlackConversationStore.get_instance()
slack_team_store = SlackTeamStore.get_instance()
async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
@dataclass
class SlackUnkownUserView(SlackViewInterface):
bot_access_token: str
@@ -73,7 +49,6 @@ class SlackUnkownUserView(SlackViewInterface):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
raise NotImplementedError
@@ -81,6 +56,9 @@ class SlackUnkownUserView(SlackViewInterface):
async def create_or_update_conversation(self, jinja_env: Environment):
raise NotImplementedError
def get_callback_id(self) -> str:
raise NotImplementedError
def get_response_msg(self) -> str:
raise NotImplementedError
@@ -100,7 +78,6 @@ class SlackNewConversationView(SlackViewInterface):
send_summary_instruction: bool
conversation_id: str
team_id: str
v1_enabled: bool
def _get_initial_prompt(self, text: str, blocks: list[dict]):
bot_id = self._get_bot_id(blocks)
@@ -119,7 +96,8 @@ class SlackNewConversationView(SlackViewInterface):
return ''
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
"Instructions passed when conversation is first initialized"
user_info: SlackUser = self.slack_to_openhands_user
messages = []
@@ -179,7 +157,7 @@ class SlackNewConversationView(SlackViewInterface):
'Attempting to start conversation without confirming selected repo from user'
)
async def save_slack_convo(self, v1_enabled: bool = False):
async def save_slack_convo(self):
if self.slack_to_openhands_user:
user_info: SlackUser = self.slack_to_openhands_user
@@ -189,59 +167,26 @@ class SlackNewConversationView(SlackViewInterface):
'channel_id': self.channel_id,
'conversation_id': self.conversation_id,
'keycloak_user_id': user_info.keycloak_user_id,
'org_id': user_info.org_id,
'parent_id': self.thread_ts or self.message_ts,
'v1_enabled': v1_enabled,
},
)
slack_conversation = SlackConversation(
conversation_id=self.conversation_id,
channel_id=self.channel_id,
keycloak_user_id=user_info.keycloak_user_id,
org_id=user_info.org_id,
parent_id=self.thread_ts
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
v1_enabled=v1_enabled,
)
await slack_conversation_store.create_slack_conversation(slack_conversation)
def _create_slack_v1_callback_processor(self) -> SlackV1CallbackProcessor:
"""Create a SlackV1CallbackProcessor for V1 conversation handling."""
return SlackV1CallbackProcessor(
slack_view_data={
'channel_id': self.channel_id,
'message_ts': self.message_ts,
'thread_ts': self.thread_ts,
'team_id': self.team_id,
'slack_user_id': self.slack_user_id,
}
)
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""Only creates a new conversation"""
"""
Only creates a new conversation
"""
self._verify_necessary_values_are_set()
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
else:
# Use existing V0 conversation service
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
return self.conversation_id
async def _create_v0_conversation(
self, jinja: Environment, provider_tokens, user_secrets
) -> None:
"""Create conversation using the legacy V0 system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
@@ -268,65 +213,11 @@ class SlackNewConversationView(SlackViewInterface):
)
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
await self.save_slack_convo()
return self.conversation_id
async def _create_v1_conversation(self, jinja: Environment) -> None:
"""Create conversation using the new V1 app conversation system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_instructions)]
)
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Get the app conversation service and start the conversation
injector_state = InjectorState()
# Create the V1 conversation start request with the callback processor
self.conversation_id = uuid4().hex
start_request = AppConversationStartRequest(
conversation_id=UUID(self.conversation_id),
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.selected_repo,
git_provider=git_provider,
title=f'Slack conversation from {self.slack_to_openhands_user.slack_display_name}',
trigger=ConversationTrigger.SLACK,
processors=[
slack_callback_processor
], # Pass the callback processor directly
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=True)
def get_callback_id(self) -> str:
return f'slack_{self.channel_id}_{self.message_ts}'
def get_response_msg(self) -> str:
user_info: SlackUser = self.slack_to_openhands_user
@@ -363,20 +254,32 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
return user_message, ''
async def send_message_to_v0_conversation(self, jinja: Environment):
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""
Send new user message to converation
"""
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
saas_user_auth: UserAuth = self.saas_user_auth
provider_tokens = await saas_user_auth.get_provider_tokens()
user_id = user_info.keycloak_user_id
# Org management in the future will get rid of this
# For now, only user that created the conversation can send follow up messages to it
if user_id != self.slack_conversation.keycloak_user_id:
raise StartingConvoException(
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
# Check if conversation has been deleted
# Update logic when soft delete is implemented
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await saas_user_auth.get_provider_tokens()
# Should we raise here if there are no provider tokens?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
@@ -401,123 +304,12 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
instructions, _ = self._get_instructions(jinja)
user_msg = MessageAction(content=instructions)
user_msg, _ = self._get_instructions(jinja)
user_msg_action = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_msg)
self.conversation_id, event_to_dict(user_msg_action)
)
async def send_message_to_v1_conversation(self, jinja: Environment):
"""Send a message to a v1 conversation using the agent server API."""
# Import services within the method to avoid circular imports
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
get_agent_server_url_from_sandbox,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
UUID(self.conversation_id)
),
UUID(self.conversation_id),
)
# 2. Sandbox lookup + validation
sandbox = await sandbox_service.get_sandbox(
app_conversation_info.sandbox_id
)
if sandbox and sandbox.status == SandboxStatus.PAUSED:
# Resume paused sandbox and wait for it to be running
logger.info('[Slack V1]: Attempting to resume paused sandbox')
await sandbox_service.resume_sandbox(app_conversation_info.sandbox_id)
# Wait for sandbox to be running (handles both fresh start and resume)
running_sandbox = await sandbox_service.wait_for_sandbox_running(
app_conversation_info.sandbox_id,
timeout=120,
poll_interval=2,
httpx_client=httpx_client,
)
assert (
running_sandbox.session_api_key is not None
), f'No session API key for sandbox: {running_sandbox.id}'
# 3. Get the agent server URL
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
# 4. Prepare the message content
user_msg, _ = self._get_instructions(jinja)
# 5. Create the message request
send_message_request = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)], run=True
)
# 6. Send the message to the agent server
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
except Exception as e:
logger.error(
'[Slack V1] Failed to send message to conversation %s: %s',
self.conversation_id,
str(e),
exc_info=True,
)
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""Send new user message to converation"""
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
# Org management in the future will get rid of this
# For now, only user that created the conversation can send follow up messages to it
if user_id != self.slack_conversation.keycloak_user_id:
raise StartingConvoException(
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
if self.slack_conversation.v1_enabled:
await self.send_message_to_v1_conversation(jinja)
else:
await self.send_message_to_v0_conversation(jinja)
return self.conversation_id
def get_response_msg(self):
@@ -569,7 +361,7 @@ class SlackFactory:
'channel_id': channel_id,
},
)
raise Exception('Did not find slack team')
raise Exception('Did not slack team')
# Determine if this is a known slack user by openhands
if not slack_user or not saas_user_auth or not channel_id:
@@ -587,7 +379,6 @@ class SlackFactory:
send_summary_instruction=False,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)
conversation: SlackConversation | None = call_async_from_sync(
@@ -618,7 +409,6 @@ class SlackFactory:
conversation_id=conversation.conversation_id,
slack_conversation=conversation,
team_id=team_id,
v1_enabled=False,
)
elif SlackFactory.did_user_select_repo_from_form(message):
@@ -636,7 +426,6 @@ class SlackFactory:
send_summary_instruction=True,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)
else:
@@ -654,5 +443,4 @@ class SlackFactory:
send_summary_instruction=True,
conversation_id='',
team_id=team_id,
v1_enabled=False,
)

View File

@@ -1,53 +0,0 @@
from storage.repository_store import RepositoryStore
from storage.stored_repository import StoredRepository
from storage.user_repo_map import UserRepositoryMap
from storage.user_repo_map_store import UserRepositoryMapStore
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import Repository
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
"""
Store repositories in DB and create user-repository mappings
Args:
repos: List of Repository objects to store
user_id: User ID associated with these repositories
"""
# Convert Repository objects to StoredRepository objects
# Convert Repository objects to UserRepositoryMap objects
stored_repos = []
user_repos = []
for repo in repos:
repo_id = f'{repo.git_provider.value}##{str(repo.id)}'
stored_repo = StoredRepository(
repo_name=repo.full_name,
repo_id=repo_id,
is_public=repo.is_public,
# Optional fields set to None by default
has_microagent=None,
has_setup_script=None,
)
stored_repos.append(stored_repo)
user_repo_map = UserRepositoryMap(user_id=user_id, repo_id=repo_id, admin=None)
user_repos.append(user_repo_map)
# Get config instance
config = OpenHandsConfig()
try:
# Store repositories in the repos table
repo_store = RepositoryStore.get_instance(config)
repo_store.store_projects(stored_repos)
# Store user-repository mappings in the user-repos table
user_repo_store = UserRepositoryMapStore.get_instance(config)
user_repo_store.store_user_repo_mappings(user_repos)
logger.info(f'Saved repos for user {user_id}')
except Exception:
logger.warning('Failed to save repos', exc_info=True)

View File

@@ -1,24 +1,19 @@
from uuid import UUID
import stripe
from server.auth.token_manager import TokenManager
from server.constants import STRIPE_API_KEY
from server.logger import logger
from sqlalchemy.orm import Session
from storage.database import session_maker
from storage.org import Org
from storage.org_store import OrgStore
from storage.stripe_customer import StripeCustomer
from openhands.utils.async_utils import call_sync_from_async
stripe.api_key = STRIPE_API_KEY
async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
async def find_customer_id_by_user_id(user_id: str) -> str | None:
# First search our own DB...
with session_maker() as session:
stripe_customer = (
session.query(StripeCustomer)
.filter(StripeCustomer.org_id == org_id)
.filter(StripeCustomer.keycloak_user_id == user_id)
.first()
)
if stripe_customer:
@@ -26,76 +21,46 @@ async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
# If that fails, fallback to stripe
search_result = await stripe.Customer.search_async(
query=f"metadata['org_id']:'{str(org_id)}'",
query=f"metadata['user_id']:'{user_id}'",
)
data = search_result.data
if not data:
logger.info(
'no_customer_for_org_id',
extra={'org_id': str(org_id)},
)
logger.info('no_customer_for_user_id', extra={'user_id': user_id})
return None
return data[0].id # type: ignore [attr-defined]
async def find_customer_id_by_user_id(user_id: str) -> str | None:
# First search our own DB...
org = await call_sync_from_async(
OrgStore.get_current_org_from_keycloak_user_id, user_id
)
if not org:
logger.warning(f'Org not found for user {user_id}')
return None
customer_id = await find_customer_id_by_org_id(org.id)
return customer_id
async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
# Get the current org for the user
org = await call_sync_from_async(
OrgStore.get_current_org_from_keycloak_user_id, user_id
)
if not org:
logger.warning(f'Org not found for user {user_id}')
return None
customer_id = await find_customer_id_by_org_id(org.id)
async def find_or_create_customer(user_id: str) -> str:
customer_id = await find_customer_id_by_user_id(user_id)
if customer_id:
return {'customer_id': customer_id, 'org_id': str(org.id)}
logger.info(
'creating_customer',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return customer_id
logger.info('creating_customer', extra={'user_id': user_id})
# Get the user info from keycloak
token_manager = TokenManager()
user_info = await token_manager.get_user_info_from_user_id(user_id) or {}
# Create the customer in stripe
customer = await stripe.Customer.create_async(
email=org.contact_email,
metadata={'org_id': str(org.id)},
email=str(user_info.get('email', '')),
metadata={'user_id': user_id},
)
# Save the stripe customer in the local db
with session_maker() as session:
session.add(
StripeCustomer(
keycloak_user_id=user_id,
org_id=org.id,
stripe_customer_id=customer.id,
)
StripeCustomer(keycloak_user_id=user_id, stripe_customer_id=customer.id)
)
session.commit()
logger.info(
'created_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
extra={'user_id': user_id, 'stripe_customer_id': customer.id},
)
return {'customer_id': customer.id, 'org_id': str(org.id)}
return customer.id
async def has_payment_method_by_user_id(user_id: str) -> bool:
async def has_payment_method(user_id: str) -> bool:
customer_id = await find_customer_id_by_user_id(user_id)
if customer_id is None:
return False
@@ -106,28 +71,3 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
f'has_payment_method:{user_id}:{customer_id}:{bool(payment_methods.data)}'
)
return bool(payment_methods.data)
async def migrate_customer(session: Session, user_id: str, org: Org):
stripe_customer = (
session.query(StripeCustomer)
.filter(StripeCustomer.keycloak_user_id == user_id)
.first()
)
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)

View File

@@ -19,7 +19,7 @@ class PRStatus(Enum):
class UserData(BaseModel):
user_id: int
username: str
keycloak_user_id: str
keycloak_user_id: str | None
@dataclass
@@ -45,3 +45,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
async def create_new_conversation(self, jinja_env: Environment, token: str):
"Create a new conversation"
raise NotImplementedError()
def get_callback_id(self) -> str:
"Unique callback id for subscribription made to EventStream for fetching agent summary"
raise NotImplementedError()

View File

@@ -7,8 +7,12 @@ from typing import TYPE_CHECKING
from jinja2 import Environment, FileSystemLoader
from server.constants import WEB_HOST
from storage.org_store import OrgStore
from storage.repository_store import RepositoryStore
from storage.stored_repository import StoredRepository
from storage.user_repo_map import UserRepositoryMap
from storage.user_repo_map_store import UserRepositoryMapStore
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events import Event, EventSource
@@ -16,12 +20,10 @@ 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
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import call_sync_from_async
if TYPE_CHECKING:
from openhands.server.conversation_manager.conversation_manager import (
@@ -33,7 +35,7 @@ if TYPE_CHECKING:
HOST = WEB_HOST
# ---- DO NOT REMOVE ----
HOST_URL = f'https://{HOST}' if 'localhost' not in HOST else f'http://{HOST}'
HOST_URL = f'https://{HOST}'
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
conversation_prefix = 'conversations/{}'
@@ -44,45 +46,13 @@ ENABLE_PROACTIVE_CONVERSATION_STARTERS = (
os.getenv('ENABLE_PROACTIVE_CONVERSATION_STARTERS', 'false').lower() == 'true'
)
def get_session_expired_message(username: str | None = None) -> str:
"""Get a user-friendly session expired message.
Used by integrations to notify users when their Keycloak offline session
has expired.
Args:
username: Optional username to mention in the message. If provided,
the message will include @username prefix (used by Git providers
like GitHub, GitLab, Slack). If None, returns a generic message
(used by Jira, Jira DC, Linear).
Returns:
A formatted session expired message
"""
if username:
return f'@{username} your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
return f'Your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
# Toggle for solvability report feature
ENABLE_SOLVABILITY_ANALYSIS = (
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
)
# Toggle for V1 GitHub resolver feature
ENABLE_V1_GITHUB_RESOLVER = (
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
)
ENABLE_V1_SLACK_RESOLVER = (
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
)
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
or 'openhands/integrations/templates/resolver/'
)
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
@@ -110,28 +80,6 @@ def get_summary_instruction():
return summary_instruction
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
if not user_id:
return False
org = await call_sync_from_async(
OrgStore.get_current_org_from_keycloak_user_id, user_id
)
if not org or org.v1_enabled is None:
return False
return org.v1_enabled
def has_exact_mention(text: str, mention: str) -> bool:
"""Check if the text contains an exact mention (not part of a larger word).
@@ -250,35 +198,18 @@ def get_summary_for_agent_state(
def get_final_agent_observation(
event_store: EventStoreABC,
) -> list[AgentStateChangedObservation]:
events = list(
event_store.search_events(
filter=EventFilter(
source=EventSource.ENVIRONMENT,
include_types=(AgentStateChangedObservation,),
),
limit=1,
reverse=True,
)
return event_store.get_matching_events(
source=EventSource.ENVIRONMENT,
event_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]:
events = list(
event_store.search_events(
filter=EventFilter(
source=EventSource.USER,
include_types=(MessageAction,),
),
limit=1,
reverse=True,
)
return event_store.get_matching_events(
source=EventSource.USER, event_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(
@@ -290,22 +221,18 @@ def extract_summary_from_event_store(
conversation_link = CONVERSATION_URL.format(conversation_id)
summary_instruction = get_summary_instruction()
instruction_events = list(
event_store.search_events(
filter=EventFilter(
query=json.dumps(summary_instruction),
source=EventSource.USER,
include_types=(MessageAction,),
),
limit=1,
reverse=True,
)
instruction_event: list[MessageAction] = event_store.get_matching_events(
query=json.dumps(summary_instruction),
source=EventSource.USER,
event_types=(MessageAction,),
limit=1,
reverse=True,
)
final_agent_observation = get_final_agent_observation(event_store)
# Find summary instruction event ID
if not instruction_events:
if len(instruction_event) == 0:
logger.warning(
'no_instruction_event_found', extra={'conversation_id': conversation_id}
)
@@ -313,19 +240,19 @@ def extract_summary_from_event_store(
final_agent_observation, conversation_link
) # Agent did not receive summary instruction
summary_events = list(
event_store.search_events(
filter=EventFilter(
source=EventSource.AGENT,
include_types=(MessageAction, AgentFinishAction),
),
limit=1,
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,
start_id=instruction_events[0].id,
limit=1,
)
)
if not summary_events:
if len(agent_messages) == 0:
logger.warning(
'no_agent_messages_found', extra={'conversation_id': conversation_id}
)
@@ -333,11 +260,10 @@ def extract_summary_from_event_store(
final_agent_observation, conversation_link
) # Agent failed to generate summary
summary_event = summary_events[0]
summary_event: MessageAction | AgentFinishAction = agent_messages[0]
if isinstance(summary_event, MessageAction):
return summary_event.content
assert isinstance(summary_event, AgentFinishAction)
return summary_event.final_thought
@@ -390,50 +316,106 @@ def append_conversation_footer(message: str, conversation_id: str) -> str:
The message with the conversation footer appended
"""
conversation_link = CONVERSATION_URL.format(conversation_id)
footer = f'\n\n[View full conversation]({conversation_link})'
footer = f'\n\n<sub>[View full conversation]({conversation_link})</sub>'
return message + footer
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
"""
Store repositories in DB and create user-repository mappings
Args:
repos: List of Repository objects to store
user_id: User ID associated with these repositories
"""
# Convert Repository objects to StoredRepository objects
# Convert Repository objects to UserRepositoryMap objects
stored_repos = []
user_repos = []
for repo in repos:
repo_id = f'{repo.git_provider.value}##{str(repo.id)}'
stored_repo = StoredRepository(
repo_name=repo.full_name,
repo_id=repo_id,
is_public=repo.is_public,
# Optional fields set to None by default
has_microagent=None,
has_setup_script=None,
)
stored_repos.append(stored_repo)
user_repo_map = UserRepositoryMap(user_id=user_id, repo_id=repo_id, admin=None)
user_repos.append(user_repo_map)
# Get config instance
config = OpenHandsConfig()
try:
# Store repositories in the repos table
repo_store = RepositoryStore.get_instance(config)
repo_store.store_projects(stored_repos)
# Store user-repository mappings in the user-repos table
user_repo_store = UserRepositoryMapStore.get_instance(config)
user_repo_store.store_user_repo_mappings(user_repos)
logger.info(f'Saved repos for user {user_id}')
except Exception:
logger.warning('Failed to save repos', exc_info=True)
def infer_repo_from_message(user_msg: str) -> list[str]:
"""
Extract all repository names in the format 'owner/repo' from various Git provider URLs
and direct mentions in text. Supports GitHub, GitLab, and BitBucket.
Args:
user_msg: Input message that may contain repository references
Returns:
List of repository names in 'owner/repo' format, empty list if none found
"""
# Normalize the message by removing extra whitespace and newlines
normalized_msg = re.sub(r'\s+', ' ', user_msg.strip())
git_url_pattern = (
r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/'
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?'
r'(?:[/?#].*?)?(?=\s|$|[^\w.-])'
)
# Pattern to match Git URLs from GitHub, GitLab, and BitBucket
# Captures: protocol, domain, owner, repo (with optional .git extension)
git_url_pattern = r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:[/?#].*?)?(?=\s|$|[^\w.-])'
# UPDATED: allow {{ owner/repo }} in addition to existing boundaries
# Pattern to match direct owner/repo mentions (e.g., "OpenHands/OpenHands")
# Must be surrounded by word boundaries or specific characters to avoid false positives
direct_pattern = (
r'(?:^|\s|{{|[\[\(\'":`])' # left boundary
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)'
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
r'(?:^|\s|[\[\(\'"])([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)(?=\s|$|[\]\)\'",.])'
)
matches: list[str] = []
matches = []
# Git URLs first (highest priority)
for owner, repo in re.findall(git_url_pattern, normalized_msg):
# First, find all Git URLs (highest priority)
git_matches = re.findall(git_url_pattern, normalized_msg)
for owner, repo in git_matches:
# Remove .git extension if present
repo = re.sub(r'\.git$', '', repo)
matches.append(f'{owner}/{repo}')
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
# Second, find all direct owner/repo mentions
direct_matches = re.findall(direct_pattern, normalized_msg)
for owner, repo in direct_matches:
full_match = f'{owner}/{repo}'
# Skip if it looks like a version number, date, or file path
if (
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match)
or re.match(r'^\d{1,2}/\d{1,2}$', full_match)
or re.match(r'^[A-Z]/[A-Z]$', full_match)
or repo.endswith(('.txt', '.md', '.py', '.js'))
or ('.' in repo and len(repo.split('.')) > 2)
):
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match) # version numbers
or re.match(r'^\d{1,2}/\d{1,2}$', full_match) # dates
or re.match(r'^[A-Z]/[A-Z]$', full_match) # single letters
or repo.endswith('.txt')
or repo.endswith('.md') # file extensions
or repo.endswith('.py')
or repo.endswith('.js')
or '.' in repo
and len(repo.split('.')) > 2
): # complex file paths
continue
# Avoid duplicates from Git URLs already found
if full_match not in matches:
matches.append(full_match)

View File

@@ -1,20 +0,0 @@
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import UserAuth
async def get_saas_user_auth(
keycloak_user_id: str, token_manager: TokenManager
) -> UserAuth:
offline_token = await token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth

View File

@@ -1,15 +1,10 @@
import logging
import os
from logging.config import fileConfig
# Suppress alembic.runtime.plugins INFO logs during import to prevent non-JSON logs in production
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
from alembic import context
from google.cloud.sql.connector import Connector
from sqlalchemy import create_engine
from storage.base import Base
target_metadata = Base.metadata

View File

@@ -20,8 +20,6 @@ down_revision = '059'
branch_labels = None
depends_on = None
# TODO: decide whether to modify this for orgs or users
def upgrade():
"""
@@ -30,10 +28,8 @@ def upgrade():
This replaces the functionality of the removed admin maintenance endpoint.
"""
# Hardcoded value to prevent migration failures when constant is removed from codebase
# This migration has already run in production, so we use the value that was current at the time
CURRENT_USER_SETTINGS_VERSION = 4
# Import here to avoid circular imports
from server.constants import CURRENT_USER_SETTINGS_VERSION
# Create a connection and bind it to a session
connection = op.get_bind()

View File

@@ -1,49 +0,0 @@
"""Create device_codes table for OAuth 2.0 Device Flow
Revision ID: 084
Revises: 083
Create Date: 2024-12-10 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '084'
down_revision = '083'
branch_labels = None
depends_on = None
def upgrade():
"""Create device_codes table for OAuth 2.0 Device Flow."""
op.create_table(
'device_codes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_code', sa.String(length=128), nullable=False),
sa.Column('user_code', sa.String(length=16), nullable=False),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('keycloak_user_id', sa.String(length=255), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('authorized_at', sa.DateTime(timezone=True), nullable=True),
# Rate limiting fields for RFC 8628 section 3.5 compliance
sa.Column('last_poll_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('current_interval', sa.Integer(), nullable=False, default=5),
sa.PrimaryKeyConstraint('id'),
)
# Create indexes for efficient lookups
op.create_index(
'ix_device_codes_device_code', 'device_codes', ['device_code'], unique=True
)
op.create_index(
'ix_device_codes_user_code', 'device_codes', ['user_code'], unique=True
)
def downgrade():
"""Drop device_codes table."""
op.drop_index('ix_device_codes_user_code', table_name='device_codes')
op.drop_index('ix_device_codes_device_code', table_name='device_codes')
op.drop_table('device_codes')

View File

@@ -1,41 +0,0 @@
"""add public column to conversation_metadata
Revision ID: 085
Revises: 084
Create Date: 2025-01-27 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '085'
down_revision: Union[str, None] = '084'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('public', sa.Boolean(), nullable=True),
)
op.create_index(
op.f('ix_conversation_metadata_public'),
'conversation_metadata',
['public'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_public'),
table_name='conversation_metadata',
)
op.drop_column('conversation_metadata', 'public')

View File

@@ -1,30 +0,0 @@
"""add v1 column to slack conversation table
Revision ID: 086
Revises: 085
Create Date: 2025-12-02 15:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '086'
down_revision: Union[str, None] = '085'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add v1 column
op.add_column(
'slack_conversation', sa.Column('v1_enabled', sa.Boolean(), nullable=True)
)
def downgrade() -> None:
# Drop v1 column
op.drop_column('slack_conversation', 'v1_enabled')

View File

@@ -1,61 +0,0 @@
"""bump condenser defaults: max_size 120->240
Revision ID: 087
Revises: 086
Create Date: 2026-01-05
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision: str = '087'
down_revision: Union[str, None] = '086'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema.
Update existing users with condenser_max_size=120 or NULL to 240.
This covers both users who had the old default (120) explicitly set
and users who had NULL (which defaulted to 120 in the application code).
The SDK default for keep_first will be used automatically.
"""
user_settings_table = table(
'user_settings',
column('condenser_max_size', sa.Integer),
)
# Update users with explicit 120 value
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size == 120)
.values(condenser_max_size=240)
)
# Update users with NULL value (which defaulted to 120 in application code)
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size.is_(None))
.values(condenser_max_size=240)
)
def downgrade() -> None:
"""Downgrade schema.
Note: This sets all 240 values back to NULL (not 120) since we can't
distinguish between users who had 120 vs NULL before the upgrade.
"""
user_settings_table = table(
'user_settings', column('condenser_max_size', sa.Integer)
)
op.execute(
user_settings_table.update()
.where(user_settings_table.c.condenser_max_size == 240)
.values(condenser_max_size=None)
)

View File

@@ -1,54 +0,0 @@
"""create blocked_email_domains table
Revision ID: 088
Revises: 087
Create Date: 2025-01-27 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '088'
down_revision: Union[str, None] = '087'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create blocked_email_domains table for storing blocked email domain patterns."""
op.create_table(
'blocked_email_domains',
sa.Column('id', sa.Integer(), sa.Identity(), nullable=False, primary_key=True),
sa.Column('domain', sa.String(), nullable=False),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.PrimaryKeyConstraint('id'),
)
# Create unique index on domain column
op.create_index(
'ix_blocked_email_domains_domain',
'blocked_email_domains',
['domain'],
unique=True,
)
def downgrade() -> None:
"""Drop blocked_email_domains table."""
op.drop_index('ix_blocked_email_domains_domain', table_name='blocked_email_domains')
op.drop_table('blocked_email_domains')

View File

@@ -1,272 +0,0 @@
"""create org tables from pgerd schema
Revision ID: 089
Revises: 088
Create Date: 2025-01-07 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '089'
down_revision: Union[str, None] = '088'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Remove current settings table
op.execute('DROP TABLE IF EXISTS settings')
# Add already_migrated column to user_settings table
op.add_column(
'user_settings',
sa.Column(
'already_migrated',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
)
# Create role table
op.create_table(
'role',
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
sa.Column('name', sa.String, nullable=False),
sa.Column('rank', sa.Integer, nullable=False),
sa.UniqueConstraint('name', name='role_name_unique'),
)
# 1. Create default roles
op.execute(
sa.text("""
INSERT INTO role (name, rank) VALUES ('owner', 10), ('admin', 20), ('user', 1000)
ON CONFLICT (name) DO NOTHING;
""")
)
# Create org table with settings fields
op.create_table(
'org',
sa.Column(
'id',
postgresql.UUID(as_uuid=True),
primary_key=True,
),
sa.Column('name', sa.String, nullable=False),
sa.Column('contact_name', sa.String, nullable=True),
sa.Column('contact_email', sa.String, nullable=True),
sa.Column('conversation_expiration', sa.Integer, nullable=True),
# Settings fields moved to org table
sa.Column('agent', sa.String, nullable=True),
sa.Column('default_max_iterations', sa.Integer, nullable=True),
sa.Column('security_analyzer', sa.String, nullable=True),
sa.Column(
'confirmation_mode',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
sa.Column('default_llm_model', sa.String, nullable=True),
sa.Column('default_llm_base_url', sa.String, nullable=True),
sa.Column('remote_runtime_resource_factor', sa.Integer, nullable=True),
sa.Column(
'enable_default_condenser',
sa.Boolean,
nullable=False,
server_default=sa.text('true'),
),
sa.Column('billing_margin', sa.Float, nullable=True),
sa.Column(
'enable_proactive_conversation_starters',
sa.Boolean,
nullable=False,
server_default=sa.text('true'),
),
sa.Column('sandbox_base_container_image', sa.String, nullable=True),
sa.Column('sandbox_runtime_container_image', sa.String, nullable=True),
sa.Column(
'org_version', sa.Integer, nullable=False, server_default=sa.text('0')
),
sa.Column('mcp_config', sa.JSON, nullable=True),
sa.Column('_search_api_key', sa.String, nullable=True),
sa.Column('_sandbox_api_key', sa.String, nullable=True),
sa.Column('max_budget_per_task', sa.Float, nullable=True),
sa.Column(
'enable_solvability_analysis',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
sa.Column('v1_enabled', sa.Boolean, nullable=True),
sa.Column('condenser_max_size', sa.Integer, nullable=True),
sa.UniqueConstraint('name', name='org_name_unique'),
)
# Create user table with user-specific settings fields
op.create_table(
'user',
sa.Column(
'id',
postgresql.UUID(as_uuid=True),
primary_key=True,
),
sa.Column('current_org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('role_id', sa.Integer, nullable=True),
sa.Column('accepted_tos', sa.DateTime, nullable=True),
sa.Column(
'enable_sound_notifications',
sa.Boolean,
nullable=True,
server_default=sa.text('false'),
),
sa.Column('language', sa.String, nullable=True),
sa.Column('user_consents_to_analytics', sa.Boolean, nullable=True),
sa.Column('email', sa.String, nullable=True),
sa.Column('email_verified', sa.Boolean, nullable=True),
sa.ForeignKeyConstraint(
['current_org_id'], ['org.id'], name='current_org_fkey'
),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name='user_role_fkey'),
)
# Create org_member table (junction table for many-to-many relationship)
op.create_table(
'org_member',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('role_id', sa.Integer, nullable=False),
sa.Column('_llm_api_key', sa.String, nullable=False),
sa.Column('max_iterations', sa.Integer, nullable=True),
sa.Column('llm_model', sa.String, nullable=True),
sa.Column('_llm_api_key_for_byor', sa.String, nullable=True),
sa.Column('llm_base_url', sa.String, nullable=True),
sa.Column('status', sa.String, nullable=True),
sa.ForeignKeyConstraint(['org_id'], ['org.id'], name='om_org_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='om_user_fkey'),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name='om_role_fkey'),
sa.PrimaryKeyConstraint('org_id', 'user_id'),
)
# Add org_id column to existing tables
# billing_sessions
op.add_column(
'billing_sessions',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'billing_sessions_org_fkey', 'billing_sessions', 'org', ['org_id'], ['id']
)
# Create conversation_metadata_saas table
op.create_table(
'conversation_metadata_saas',
sa.Column('conversation_id', sa.String(), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
['user_id'], ['user.id'], name='conversation_metadata_saas_user_fkey'
),
sa.ForeignKeyConstraint(
['org_id'], ['org.id'], name='conversation_metadata_saas_org_fkey'
),
sa.PrimaryKeyConstraint('conversation_id'),
)
# custom_secrets
op.add_column(
'custom_secrets',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'custom_secrets_org_fkey', 'custom_secrets', 'org', ['org_id'], ['id']
)
# api_keys
op.add_column(
'api_keys', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True)
)
op.create_foreign_key('api_keys_org_fkey', 'api_keys', 'org', ['org_id'], ['id'])
# slack_conversation
op.add_column(
'slack_conversation',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'slack_conversation_org_fkey', 'slack_conversation', 'org', ['org_id'], ['id']
)
# slack_users
op.add_column(
'slack_users', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True)
)
op.create_foreign_key(
'slack_users_org_fkey', 'slack_users', 'org', ['org_id'], ['id']
)
# stripe_customers
op.alter_column(
'stripe_customers',
'keycloak_user_id',
existing_type=sa.String(),
nullable=True,
)
op.add_column(
'stripe_customers',
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'stripe_customers_org_fkey', 'stripe_customers', 'org', ['org_id'], ['id']
)
def downgrade() -> None:
# Drop already_migrated column from user_settings table
op.drop_column('user_settings', 'already_migrated')
# Drop foreign keys and columns added to existing tables
op.drop_constraint(
'stripe_customers_org_fkey', 'stripe_customers', type_='foreignkey'
)
op.drop_column('stripe_customers', 'org_id')
op.alter_column(
'stripe_customers',
'keycloak_user_id',
existing_type=sa.String(),
nullable=False,
)
op.drop_constraint('slack_users_org_fkey', 'slack_users', type_='foreignkey')
op.drop_column('slack_users', 'org_id')
op.drop_constraint(
'slack_conversation_org_fkey', 'slack_conversation', type_='foreignkey'
)
op.drop_column('slack_conversation', 'org_id')
op.drop_constraint('api_keys_org_fkey', 'api_keys', type_='foreignkey')
op.drop_column('api_keys', 'org_id')
op.drop_constraint('custom_secrets_org_fkey', 'custom_secrets', type_='foreignkey')
op.drop_column('custom_secrets', 'org_id')
# Drop conversation_metadata_saas table
op.drop_table('conversation_metadata_saas')
op.drop_constraint(
'billing_sessions_org_fkey', 'billing_sessions', type_='foreignkey'
)
op.drop_column('billing_sessions', 'org_id')
# Drop tables in reverse order due to foreign key constraints
op.drop_table('org_member')
op.drop_table('user')
op.drop_table('org')
op.drop_table('role')

View File

@@ -1,28 +0,0 @@
"""Add git_user_name and git_user_email columns to user table.
Revision ID: 090
Revises: 089
Create Date: 2025-01-22
"""
import sqlalchemy as sa
from alembic import op
revision = '090'
down_revision = '089'
def upgrade() -> None:
op.add_column(
'user',
sa.Column('git_user_name', sa.String, nullable=True),
)
op.add_column(
'user',
sa.Column('git_user_email', sa.String, nullable=True),
)
def downgrade() -> None:
op.drop_column('user', 'git_user_email')
op.drop_column('user', 'git_user_name')

View File

@@ -1,46 +0,0 @@
"""Add byor_export_enabled flag to org table.
Revision ID: 091
Revises: 090
Create Date: 2025-01-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '091'
down_revision: Union[str, None] = '090'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add byor_export_enabled column to org table with default false
op.add_column(
'org',
sa.Column(
'byor_export_enabled',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
# Set byor_export_enabled to true for orgs that have completed billing sessions
op.execute(
sa.text("""
UPDATE org SET byor_export_enabled = TRUE
WHERE id IN (
SELECT DISTINCT org_id FROM billing_sessions
WHERE status = 'completed' AND org_id IS NOT NULL
)
""")
)
def downgrade() -> None:
op.drop_column('org', 'byor_export_enabled')

View File

@@ -1,29 +0,0 @@
"""Rename 'user' role to 'member' in role table.
Revision ID: 092
Revises: 091
Create Date: 2025-02-12 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '092'
down_revision: Union[str, None] = '091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename 'user' role to 'member' for clarity
# This avoids confusion between the 'user' role and the 'user' entity/account
op.execute(sa.text("UPDATE role SET name = 'member' WHERE name = 'user'"))
def downgrade() -> None:
# Revert 'member' role back to 'user'
op.execute(sa.text("UPDATE role SET name = 'user' WHERE name = 'member'"))

View File

@@ -1,37 +0,0 @@
"""Add pending_free_credits flag to org table.
Revision ID: 093
Revises: 092
Create Date: 2025-02-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '093'
down_revision: Union[str, None] = '092'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add pending_free_credits column to org table with default false.
# New orgs will have this set to TRUE at creation time.
# Existing orgs default to FALSE (not eligible - they already got $10 at signup).
op.add_column(
'org',
sa.Column(
'pending_free_credits',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
def downgrade() -> None:
op.drop_column('org', 'pending_free_credits')

View File

@@ -1,110 +0,0 @@
"""create org_invitation table
Revision ID: 094
Revises: 093
Create Date: 2026-02-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '094'
down_revision: Union[str, None] = '093'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create org_invitation table
op.create_table(
'org_invitation',
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
sa.Column('token', sa.String(64), nullable=False),
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('role_id', sa.Integer, nullable=False),
sa.Column('inviter_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
'status',
sa.String(20),
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column(
'created_at',
sa.DateTime,
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('accepted_at', sa.DateTime, nullable=True),
sa.Column('accepted_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
# Foreign key constraints
sa.ForeignKeyConstraint(
['org_id'],
['org.id'],
name='org_invitation_org_fkey',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['role_id'],
['role.id'],
name='org_invitation_role_fkey',
),
sa.ForeignKeyConstraint(
['inviter_id'],
['user.id'],
name='org_invitation_inviter_fkey',
),
sa.ForeignKeyConstraint(
['accepted_by_user_id'],
['user.id'],
name='org_invitation_accepter_fkey',
),
)
# Create indexes
op.create_index(
'ix_org_invitation_token',
'org_invitation',
['token'],
unique=True,
)
op.create_index(
'ix_org_invitation_org_id',
'org_invitation',
['org_id'],
)
op.create_index(
'ix_org_invitation_email',
'org_invitation',
['email'],
)
op.create_index(
'ix_org_invitation_status',
'org_invitation',
['status'],
)
# Composite index for checking pending invitations
op.create_index(
'ix_org_invitation_org_email_status',
'org_invitation',
['org_id', 'email', 'status'],
)
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_org_invitation_org_email_status', table_name='org_invitation')
op.drop_index('ix_org_invitation_status', table_name='org_invitation')
op.drop_index('ix_org_invitation_email', table_name='org_invitation')
op.drop_index('ix_org_invitation_org_id', table_name='org_invitation')
op.drop_index('ix_org_invitation_token', table_name='org_invitation')
# Drop table
op.drop_table('org_invitation')

View File

@@ -1,37 +0,0 @@
"""Drop pending_free_credits column from org table.
Revision ID: 095
Revises: 094
Create Date: 2025-02-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '095'
down_revision: Union[str, None] = '094'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop the pending_free_credits column from org table.
# This column was used for tracking free credit eligibility but is no longer needed.
op.drop_column('org', 'pending_free_credits')
def downgrade() -> None:
# Re-add pending_free_credits column with default false.
op.add_column(
'org',
sa.Column(
'pending_free_credits',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)

View File

@@ -1,67 +0,0 @@
"""Create resend_synced_users table.
Revision ID: 096
Revises: 095
Create Date: 2025-02-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '096'
down_revision: Union[str, None] = '095'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create resend_synced_users table for tracking users synced to Resend audiences."""
op.create_table(
'resend_synced_users',
sa.Column(
'id',
sa.UUID(as_uuid=True),
nullable=False,
primary_key=True,
),
sa.Column('email', sa.String(), nullable=False),
sa.Column('audience_id', sa.String(), nullable=False),
sa.Column(
'synced_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column('keycloak_user_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'email', 'audience_id', name='uq_resend_synced_email_audience'
),
)
# Create index on email for fast lookups
op.create_index(
'ix_resend_synced_users_email',
'resend_synced_users',
['email'],
)
# Create index on audience_id for filtering by audience
op.create_index(
'ix_resend_synced_users_audience_id',
'resend_synced_users',
['audience_id'],
)
def downgrade() -> None:
"""Drop resend_synced_users table."""
op.drop_index(
'ix_resend_synced_users_audience_id', table_name='resend_synced_users'
)
op.drop_index('ix_resend_synced_users_email', table_name='resend_synced_users')
op.drop_table('resend_synced_users')

View File

@@ -1,41 +0,0 @@
"""Add session_api_key_hash to v1_remote_sandbox table
Revision ID: 097
Revises: 096
Create Date: 2025-02-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '097'
down_revision: Union[str, None] = '096'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add session_api_key_hash column to v1_remote_sandbox table."""
op.add_column(
'v1_remote_sandbox',
sa.Column('session_api_key_hash', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
'v1_remote_sandbox',
['session_api_key_hash'],
unique=False,
)
def downgrade() -> None:
"""Remove session_api_key_hash column from v1_remote_sandbox table."""
op.drop_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
table_name='v1_remote_sandbox',
)
op.drop_column('v1_remote_sandbox', 'session_api_key_hash')

12023
enterprise/poetry.lock generated

File diff suppressed because one or more lines are too long

View File

@@ -29,6 +29,7 @@ cloud-sql-python-connector = "^1.16.0"
psycopg2-binary = "^2.9.10"
pg8000 = "^1.31.2"
stripe = "^11.5.0"
prometheus-fastapi-instrumentator = "^7.0.2"
python-json-logger = "^3.2.1"
python-keycloak = "^5.3.1"
asyncpg = "^0.30.0"
@@ -43,13 +44,6 @@ coredis = "^4.22.0"
httpx = "*"
scikit-learn = "^1.7.0"
shap = "^0.48.0"
google-cloud-recaptcha-enterprise = "^1.24.0"
# Dependencies previously only in Dockerfile, now managed via poetry.lock
prometheus-client = "^0.24.0"
pandas = "^2.2.0"
numpy = "^2.2.0"
mcp = "^1.10.0"
pillow = "^12.1.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"

View File

@@ -4,10 +4,6 @@ from dotenv import load_dotenv
load_dotenv()
# Ensure SAAS configuration is used
if not os.getenv('OPENHANDS_CONFIG_CLS'):
os.environ['OPENHANDS_CONFIG_CLS'] = 'server.config.SaaSServerConfig'
import socketio # noqa: E402
from fastapi import Request, status # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
@@ -22,6 +18,7 @@ from server.auth.constants import ( # noqa: E402
)
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
from server.logger import logger # noqa: E402
from server.metrics import metrics_app # noqa: E402
from server.middleware import SetAuthCookieMiddleware # noqa: E402
from server.rate_limit import setup_rate_limit_handler # noqa: E402
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
@@ -37,22 +34,8 @@ from server.routes.integration.jira_dc import jira_dc_integration_router # noqa
from server.routes.integration.linear import linear_integration_router # noqa: E402
from server.routes.integration.slack import slack_router # noqa: E402
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
from server.routes.oauth_device import oauth_device_router # noqa: E402
from server.routes.org_invitations import ( # noqa: E402
accept_router as invitation_accept_router,
)
from server.routes.org_invitations import ( # noqa: E402
invitation_router,
)
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
from server.sharing.shared_event_router import ( # noqa: E402
router as shared_event_router,
)
from openhands.server.app import app as base_app # noqa: E402
from openhands.server.listen_socket import sio # noqa: E402
@@ -71,28 +54,21 @@ def is_saas():
return {'saas': True}
# This requires a trailing slash to access, like /api/metrics/
base_app.mount('/internal/metrics', metrics_app())
base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(
billing_router
) # Add routes for credit management and Stripe payment integration
base_app.include_router(shared_conversation_router)
base_app.include_router(shared_event_router)
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
if GITHUB_APP_CLIENT_ID:
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
from integrations.github.github_v1_callback_processor import ( # noqa: E402
GithubV1CallbackProcessor,
)
from server.routes.integration.github import github_integration_router # noqa: E402
# Bludgeon mypy into not deleting my import
logger.debug(f'Loaded {GithubV1CallbackProcessor.__name__}')
base_app.include_router(
github_integration_router
) # Add additional route for integration webhook events
@@ -104,9 +80,6 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)
add_debugging_routes(
base_app
@@ -124,7 +97,6 @@ base_app.include_router(
event_webhook_router
) # Add routes for Events in nested runtimes
base_app.add_middleware(
CORSMiddleware,
allow_origins=PERMITTED_CORS_ORIGINS,

View File

@@ -38,9 +38,3 @@ class ExpiredError(AuthError):
"""Error when a token has expired (Usually the refresh token)"""
pass
class TokenRefreshError(AuthError):
"""Error when token refresh fails due to timeout or lock contention"""
pass

View File

@@ -1,306 +0,0 @@
"""
Permission-based authorization dependencies for API endpoints.
This module provides FastAPI dependencies for checking user permissions
within organizations. It uses a permission-based authorization model where
roles (owner, admin, member) are mapped to specific permissions.
Permissions are defined in the Permission enum and mapped to roles via
ROLE_PERMISSIONS. This allows fine-grained access control while maintaining
the familiar role-based hierarchy.
Usage:
from server.auth.authorization import (
Permission,
require_permission,
)
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
# Only users with VIEW_LLM_SETTINGS permission can access
...
@router.patch('/{org_id}/settings')
async def update_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.EDIT_LLM_SETTINGS)),
):
# Only users with EDIT_LLM_SETTINGS permission can access
...
"""
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
class Permission(str, Enum):
"""Permissions that can be assigned to roles."""
# Secrets
MANAGE_SECRETS = 'manage_secrets'
# MCP
MANAGE_MCP = 'manage_mcp'
# Integrations
MANAGE_INTEGRATIONS = 'manage_integrations'
# Application Settings
MANAGE_APPLICATION_SETTINGS = 'manage_application_settings'
# API Keys
MANAGE_API_KEYS = 'manage_api_keys'
# LLM Settings
VIEW_LLM_SETTINGS = 'view_llm_settings'
EDIT_LLM_SETTINGS = 'edit_llm_settings'
# Billing
VIEW_BILLING = 'view_billing'
ADD_CREDITS = 'add_credits'
# Organization Members
INVITE_USER_TO_ORGANIZATION = 'invite_user_to_organization'
CHANGE_USER_ROLE_MEMBER = 'change_user_role:member'
CHANGE_USER_ROLE_ADMIN = 'change_user_role:admin'
CHANGE_USER_ROLE_OWNER = 'change_user_role:owner'
# Organization Management
VIEW_ORG_SETTINGS = 'view_org_settings'
CHANGE_ORGANIZATION_NAME = 'change_organization_name'
DELETE_ORGANIZATION = 'delete_organization'
# Temporary permissions until we finish the API updates.
EDIT_ORG_SETTINGS = 'edit_org_settings'
class RoleName(str, Enum):
"""Role names used in the system."""
OWNER = 'owner'
ADMIN = 'admin'
MEMBER = 'member'
# Permission mappings for each role
ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
RoleName.OWNER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
Permission.CHANGE_USER_ROLE_OWNER,
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
]
),
RoleName.ADMIN: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
]
),
RoleName.MEMBER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
# Settings (View only)
Permission.VIEW_ORG_SETTINGS,
Permission.VIEW_LLM_SETTINGS,
]
),
}
def get_user_org_role(user_id: str, org_id: UUID | None) -> Role | None:
"""
Get the user's role in an organization (synchronous version).
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID, or None to use the user's current organization
Returns:
Role object if user is a member, None otherwise
"""
from uuid import UUID as parse_uuid
if org_id is None:
org_member = OrgMemberStore.get_org_member_for_current_org(parse_uuid(user_id))
else:
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
if not org_member:
return None
return RoleStore.get_role_by_id(org_member.role_id)
async def get_user_org_role_async(user_id: str, org_id: UUID | None) -> Role | None:
"""
Get the user's role in an organization (async version).
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID, or None to use the user's current organization
Returns:
Role object if user is a member, None otherwise
"""
from uuid import UUID as parse_uuid
if org_id is None:
org_member = await OrgMemberStore.get_org_member_for_current_org_async(
parse_uuid(user_id)
)
else:
org_member = await OrgMemberStore.get_org_member_async(
org_id, parse_uuid(user_id)
)
if not org_member:
return None
return await RoleStore.get_role_by_id_async(org_member.role_id)
def get_role_permissions(role_name: str) -> frozenset[Permission]:
"""
Get the permissions for a role.
Args:
role_name: Name of the role
Returns:
Set of permissions for the role
"""
try:
role_enum = RoleName(role_name)
return ROLE_PERMISSIONS.get(role_enum, frozenset())
except ValueError:
return frozenset()
def has_permission(user_role: Role, permission: Permission) -> bool:
"""
Check if a role has a specific permission.
Args:
user_role: User's Role object
permission: Permission to check
Returns:
True if the role has the permission
"""
permissions = get_role_permissions(user_role.name)
return permission in permissions
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
...
Args:
permission: The permission required to access the endpoint
Returns:
Dependency function that validates permission and returns user_id
"""
async def permission_checker(
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_role = await get_user_org_role_async(user_id, org_id)
if not user_role:
logger.warning(
'User not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if not has_permission(user_role, permission):
logger.warning(
'Insufficient permissions',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
'required_permission': permission.value,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Requires {permission.value} permission',
)
return user_id
return permission_checker

View File

@@ -38,18 +38,3 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
'y',
'on',
)
DUPLICATE_EMAIL_CHECK = os.getenv('DUPLICATE_EMAIL_CHECK', 'true') in ('1', 'true')
# reCAPTCHA Enterprise
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',
'SUSPICIOUS_ACCOUNT_CREATION',
'RELATED_ACCOUNTS_NUMBER_HIGH',
}

View File

@@ -1,67 +0,0 @@
from storage.blocked_email_domain_store import BlockedEmailDomainStore
from storage.database import session_maker
from openhands.core.logger import openhands_logger as logger
class DomainBlocker:
def __init__(self, store: BlockedEmailDomainStore) -> None:
logger.debug('Initializing DomainBlocker')
self.store = store
def _extract_domain(self, email: str) -> str | None:
"""Extract and normalize email domain from email address"""
if not email:
return None
try:
# Extract domain part after @
if '@' not in email:
return None
domain = email.split('@')[1].strip().lower()
return domain if domain else None
except Exception:
logger.debug(f'Error extracting domain from email: {email}', exc_info=True)
return None
def is_domain_blocked(self, email: str) -> bool:
"""Check if email domain is blocked by querying the database directly via SQL.
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'
The blocking logic is handled efficiently in SQL, avoiding the need to load
all blocked domains into memory.
"""
if not email:
logger.debug('No email provided for domain check')
return False
domain = self._extract_domain(email)
if not domain:
logger.debug(f'Could not extract domain from email: {email}')
return False
try:
# Query database directly via SQL to check if domain is blocked
is_blocked = self.store.is_domain_blocked(domain)
if is_blocked:
logger.warning(f'Email domain {domain} is blocked for email: {email}')
else:
logger.debug(f'Email domain {domain} is not blocked')
return is_blocked
except Exception as e:
logger.error(
f'Error checking if domain is blocked for email {email}: {e}',
exc_info=True,
)
# Fail-safe: if database query fails, don't block (allow auth to proceed)
return False
# Initialize store and domain blocker
_store = BlockedEmailDomainStore(session_maker=session_maker)
domain_blocker = DomainBlocker(store=_store)

View File

@@ -1,109 +0,0 @@
"""Email validation utilities for preventing duplicate signups with + modifier."""
import re
def extract_base_email(email: str) -> str | None:
"""Extract base email from an email address.
For emails with + modifier, extracts the base email (local part before + and @, plus domain).
For emails without + modifier, returns the email as-is.
Examples:
extract_base_email("joe+test@example.com") -> "joe@example.com"
extract_base_email("joe@example.com") -> "joe@example.com"
extract_base_email("joe+openhands+test@example.com") -> "joe@example.com"
Args:
email: The email address to process
Returns:
The base email address, or None if email format is invalid
"""
if not email or '@' not in email:
return None
try:
local_part, domain = email.rsplit('@', 1)
# Extract the part before + if it exists
base_local = local_part.split('+', 1)[0]
return f'{base_local}@{domain}'
except (ValueError, AttributeError):
return None
def has_plus_modifier(email: str) -> bool:
"""Check if an email address contains a + modifier.
Args:
email: The email address to check
Returns:
True if email contains + before @, False otherwise
"""
if not email or '@' not in email:
return False
try:
local_part, _ = email.rsplit('@', 1)
return '+' in local_part
except (ValueError, AttributeError):
return False
def matches_base_email(email: str, base_email: str) -> bool:
"""Check if an email matches a base email pattern.
An email matches if:
- It is exactly the base email (e.g., joe@example.com)
- It has the same base local part and domain, with or without + modifier
(e.g., joe+test@example.com matches base joe@example.com)
Args:
email: The email address to check
base_email: The base email to match against
Returns:
True if email matches the base pattern, False otherwise
"""
if not email or not base_email:
return False
# Extract base from both emails for comparison
email_base = extract_base_email(email)
base_email_normalized = extract_base_email(base_email)
if not email_base or not base_email_normalized:
return False
# Emails match if they have the same base
return email_base.lower() == base_email_normalized.lower()
def get_base_email_regex_pattern(base_email: str) -> re.Pattern | None:
"""Generate a regex pattern to match emails with the same base.
For base_email "joe@example.com", the pattern will match:
- joe@example.com
- joe+anything@example.com
Args:
base_email: The base email address
Returns:
A compiled regex pattern, or None if base_email is invalid
"""
base = extract_base_email(base_email)
if not base:
return None
try:
local_part, domain = base.rsplit('@', 1)
# Escape special regex characters in local part and domain
escaped_local = re.escape(local_part)
escaped_domain = re.escape(domain)
# Pattern: joe@example.com OR joe+anything@example.com
pattern = rf'^{escaped_local}(\+[^@\s]+)?@{escaped_domain}$'
return re.compile(pattern, re.IGNORECASE)
except (ValueError, AttributeError):
return None

View File

@@ -1,36 +1,12 @@
import asyncio
from integrations.gitlab.gitlab_service import SaaSGitLabService
from pydantic import SecretStr
from sqlalchemy import select
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
from openhands.server.types import AppMode
async def _user_has_gitlab_provider(user_id: str) -> bool:
"""Check if the user has authenticated with GitLab.
Args:
user_id: The Keycloak user ID
Returns:
True if the user has a GitLab provider token, False otherwise
"""
# Lazy import to avoid circular dependency issues at module load time
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
async with a_session_maker() as session:
result = await session.execute(
select(AuthTokens).where(
AuthTokens.keycloak_user_id == user_id,
AuthTokens.identity_provider == ProviderType.GITLAB.value,
)
)
return result.scalars().first() is not None
def schedule_gitlab_repo_sync(
user_id: str, keycloak_access_token: SecretStr | None = None
) -> None:
@@ -39,26 +15,10 @@ def schedule_gitlab_repo_sync(
Because the outer call is already a background task, we instruct the service
to store repository data synchronously (store_in_background=False) to avoid
nested background tasks while still keeping the overall operation async.
The sync is only performed if the user has authenticated with GitLab.
"""
async def _run():
try:
# Check if the user has a GitLab provider token before syncing
if not await _user_has_gitlab_provider(user_id):
logger.debug(
'gitlab_repo_sync_skipped: user has no GitLab provider',
extra={'user_id': user_id},
)
return
# Lazy import to avoid circular dependency:
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
# -> integrations.gitlab.gitlab_service (circular)
from integrations.gitlab.gitlab_service import SaaSGitLabService
service = SaaSGitLabService(
external_auth_id=user_id, external_auth_token=keycloak_access_token
)

View File

@@ -1,164 +0,0 @@
import hashlib
import hmac
from dataclasses import dataclass
from google.cloud import recaptchaenterprise_v1
from server.auth.constants import (
RECAPTCHA_BLOCK_THRESHOLD,
RECAPTCHA_HMAC_SECRET,
RECAPTCHA_PROJECT_ID,
RECAPTCHA_SITE_KEY,
SUSPICIOUS_LABELS,
)
from openhands.core.logger import openhands_logger as logger
@dataclass
class AssessmentResult:
"""Result of a reCAPTCHA Enterprise assessment."""
name: str
score: float
valid: bool
action_valid: bool
reason_codes: list[str]
account_defender_labels: list[str]
allowed: bool
class RecaptchaService:
"""Service for creating reCAPTCHA Enterprise assessments."""
def __init__(self):
self._client = None
self.project_id = RECAPTCHA_PROJECT_ID
self.site_key = RECAPTCHA_SITE_KEY
@property
def client(self):
"""Lazily initialize the reCAPTCHA client to avoid credential errors at import time."""
if self._client is None:
self._client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
return self._client
def hash_account_id(self, email: str) -> str:
"""Hash email using SHA256-HMAC for Account Defender.
Args:
email: The user's email address.
Returns:
Hex-encoded HMAC-SHA256 hash of the lowercase email.
"""
return hmac.new(
RECAPTCHA_HMAC_SECRET.encode(),
email.lower().encode(),
hashlib.sha256,
).hexdigest()
def create_assessment(
self,
token: str,
action: str,
user_ip: str,
user_agent: str,
email: str | None = None,
user_id: str | None = None,
) -> AssessmentResult:
"""Create a reCAPTCHA Enterprise assessment.
Args:
token: The reCAPTCHA token from the frontend.
action: The expected action name (e.g., 'LOGIN').
user_ip: The user's IP address.
user_agent: The user's browser user agent.
email: Optional email for Account Defender hashing.
user_id: Optional Keycloak user ID for logging correlation.
Returns:
AssessmentResult with score, validity, and allowed status.
"""
event = recaptchaenterprise_v1.Event()
event.site_key = self.site_key
event.token = token
event.user_ip_address = user_ip
event.user_agent = user_agent
event.expected_action = action
# Account Defender: use user_info.account_id (hashed_account_id is deprecated)
if email:
user_info = recaptchaenterprise_v1.UserInfo()
user_info.account_id = self.hash_account_id(email)
# Also include email as a user identifier for better fraud detection
user_info.user_ids.append(recaptchaenterprise_v1.UserId(email=email))
event.user_info = user_info
assessment = recaptchaenterprise_v1.Assessment()
assessment.event = event
request = recaptchaenterprise_v1.CreateAssessmentRequest()
request.assessment = assessment
request.parent = f'projects/{self.project_id}'
response = self.client.create_assessment(request)
# Capture assessment name for potential annotation later
# Format: projects/{project_id}/assessments/{assessment_id}
assessment_name = response.name
token_properties = response.token_properties
risk_analysis = response.risk_analysis
score = risk_analysis.score
valid = token_properties.valid
action_valid = token_properties.action == action
reason_codes = [str(r) for r in risk_analysis.reasons]
# Extract Account Defender labels
account_defender_labels = []
if response.account_defender_assessment:
account_defender_labels = [
str(label) for label in response.account_defender_assessment.labels
]
# Check if any suspicious labels are present
has_suspicious_labels = bool(set(account_defender_labels) & SUSPICIOUS_LABELS)
# Block if: invalid token, wrong action, low score, OR suspicious Account Defender labels
allowed = (
valid
and action_valid
and score >= RECAPTCHA_BLOCK_THRESHOLD
and not has_suspicious_labels
)
logger.info(
'recaptcha_assessment',
extra={
'assessment_name': assessment_name,
'score': score,
'valid': valid,
'action_valid': action_valid,
'reasons': reason_codes,
'account_defender_labels': account_defender_labels,
'has_suspicious_labels': has_suspicious_labels,
'allowed': allowed,
'user_ip': user_ip,
'user_id': user_id,
'email': email,
},
)
return AssessmentResult(
name=assessment_name,
score=score,
valid=valid,
action_valid=action_valid,
reason_codes=reason_codes,
account_defender_labels=account_defender_labels,
allowed=allowed,
)
recaptcha_service = RecaptchaService()

View File

@@ -13,7 +13,6 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.domain_blocker import domain_blocker
from server.auth.token_manager import TokenManager
from server.config import get_config
from server.logger import logger
@@ -77,15 +76,6 @@ class SaasUserAuth(UserAuth):
self.access_token = SecretStr(tokens['access_token'])
self.refresh_token = SecretStr(tokens['refresh_token'])
self.refreshed = True
if not self.email or not self.email_verified or not self.user_id:
# We don't need to verify the signature here because we just refreshed
# this token from the IDP via token_manager.refresh()
access_token_payload = jwt.decode(
tokens['access_token'], options={'verify_signature': False}
)
self.user_id = access_token_payload['sub']
self.email = access_token_payload['email']
self.email_verified = access_token_payload['email_verified']
def _is_token_expired(self, token: SecretStr):
logger.debug('saas_user_auth_is_token_expired')
@@ -112,6 +102,7 @@ class SaasUserAuth(UserAuth):
return settings
settings_store = await self.get_user_settings_store()
settings = await settings_store.load()
# If load() returned None, should settings be created?
if settings:
settings.email = self.email
settings.email_verified = self.email_verified
@@ -162,10 +153,8 @@ class SaasUserAuth(UserAuth):
try:
# TODO: I think we can do this in a single request if we refactor
with session_maker() as session:
tokens = (
session.query(AuthTokens)
.where(AuthTokens.keycloak_user_id == self.user_id)
.all()
tokens = session.query(AuthTokens).where(
AuthTokens.keycloak_user_id == self.user_id
)
for token in tokens:
@@ -214,15 +203,6 @@ class SaasUserAuth(UserAuth):
self.settings_store = settings_store
return settings_store
async def get_mcp_api_key(self) -> str:
api_key_store = ApiKeyStore.get_instance()
mcp_api_key = await api_key_store.retrieve_mcp_api_key(self.user_id)
if not mcp_api_key:
mcp_api_key = await api_key_store.create_api_key(
self.user_id, 'MCP_API_KEY', None
)
return mcp_api_key
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')
@@ -263,12 +243,7 @@ def get_api_key_from_header(request: Request):
# This is a temp hack
# Streamable HTTP MCP Client works via redirect requests, but drops the Authorization header for reason
# We include `X-Session-API-Key` header by default due to nested runtimes, so it used as a drop in replacement here
session_api_key = request.headers.get('X-Session-API-Key')
if session_api_key:
return session_api_key
# Fallback to X-Access-Token header as an additional option
return request.headers.get('X-Access-Token')
return request.headers.get('X-Session-API-Key')
async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
@@ -282,13 +257,11 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
if not user_id:
return None
offline_token = await token_manager.load_offline_token(user_id)
saas_user_auth = SaasUserAuth(
return SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
)
await saas_user_auth.refresh()
return saas_user_auth
except Exception as exc:
raise BearerTokenError from exc
@@ -325,16 +298,6 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
user_id = access_token_payload['sub']
email = access_token_payload['email']
email_verified = access_token_payload['email_verified']
# Check if email domain is blocked
if email and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for existing user with email: {email}'
)
raise AuthError(
'Access denied: Your email domain is not allowed to access this service'
)
logger.debug('saas_user_auth_from_signed_token:return')
return SaasUserAuth(

View File

@@ -1,4 +1,3 @@
import asyncio
import base64
import hashlib
import json
@@ -14,13 +13,10 @@ from keycloak.exceptions import (
KeycloakAuthenticationError,
KeycloakConnectionError,
KeycloakError,
KeycloakPostError,
)
from server.auth.auth_error import ExpiredError
from server.auth.constants import (
BITBUCKET_APP_CLIENT_ID,
BITBUCKET_APP_CLIENT_SECRET,
DUPLICATE_EMAIL_CHECK,
GITHUB_APP_CLIENT_ID,
GITHUB_APP_CLIENT_SECRET,
GITLAB_APP_CLIENT_ID,
@@ -29,11 +25,6 @@ from server.auth.constants import (
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
)
from server.auth.email_validation import (
extract_base_email,
get_base_email_regex_pattern,
matches_base_email,
)
from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
from server.config import get_config
from server.logger import logger
@@ -46,13 +37,8 @@ from storage.offline_token_store import OfflineTokenStore
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
from openhands.integrations.service_types import ProviderType
from openhands.server.types import SessionExpiredError
from openhands.utils.http_session import httpx_verify_option
# HTTP timeout for external IDP calls (in seconds)
# This prevents indefinite blocking if an IDP is slow or unresponsive
IDP_HTTP_TIMEOUT = 15.0
def _before_sleep_callback(retry_state: RetryCallState) -> None:
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
@@ -206,9 +192,7 @@ class TokenManager:
access_token: str,
idp: ProviderType,
) -> dict[str, str | int]:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
headers = {
@@ -367,9 +351,7 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitHub token')
@@ -395,9 +377,7 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitLab token')
@@ -425,9 +405,7 @@ class TokenManager:
'refresh_token': refresh_token,
}
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, data=data, headers=headers)
response.raise_for_status()
logger.info('Successfully refreshed Bitbucket token')
@@ -439,8 +417,6 @@ class TokenManager:
access_token = data.get('access_token')
refresh_token = data.get('refresh_token')
if not access_token or not refresh_token:
if data.get('error') == 'bad_refresh_token':
raise ExpiredError()
raise ValueError(
'Failed to refresh token: missing access_token or refresh_token in response.'
)
@@ -483,14 +459,6 @@ class TokenManager:
except KeycloakConnectionError:
logger.exception('KeycloakConnectionError when refreshing token')
raise
except KeycloakPostError as e:
error_message = str(e)
if 'invalid_grant' in error_message or 'session not found' in error_message:
logger.warning(f'User session expired or invalid: {error_message}')
raise SessionExpiredError(
'Your session has expired. Please login again.'
) from e
raise
@retry(
stop=stop_after_attempt(2),
@@ -541,187 +509,6 @@ class TokenManager:
logger.info(f'Got user ID {keycloak_user_id} from email: {email}')
return keycloak_user_id
async def _query_users_by_wildcard_pattern(
self, local_part: str, domain: str
) -> dict[str, dict]:
"""Query Keycloak for users matching a wildcard email pattern.
Tries multiple query methods to find users with emails matching
the pattern {local_part}*@{domain}. This catches the base email
and all + modifier variants.
Args:
local_part: The local part of the email (before @)
domain: The domain part of the email (after @)
Returns:
Dictionary mapping user IDs to user objects
"""
keycloak_admin = get_keycloak_admin(self.external)
all_users = {}
# Query for users with emails matching the base pattern using wildcard
# Pattern: {local_part}*@{domain} - catches base email and all + variants
# This may also catch unintended matches (e.g., joesmith@example.com), but
# they will be filtered out by the regex pattern check later
# Use 'search' parameter for Keycloak 26+ (better wildcard support)
wildcard_queries = [
{'search': f'{local_part}*@{domain}'}, # Try 'search' parameter first
{'q': f'email:{local_part}*@{domain}'}, # Fallback to 'q' parameter
]
for query_params in wildcard_queries:
try:
users = await keycloak_admin.a_get_users(query_params)
for user in users:
all_users[user.get('id')] = user
break # Success, no need to try fallback
except Exception as e:
logger.debug(
f'Wildcard query failed with {list(query_params.keys())[0]}: {e}'
)
continue # Try next query method
return all_users
def _find_duplicate_in_users(
self, users: dict[str, dict], base_email: str, current_user_id: str
) -> bool:
"""Check if any user in the provided list matches the base email pattern.
Filters users to find duplicates that match the base email pattern,
excluding the current user.
Args:
users: Dictionary mapping user IDs to user objects
base_email: The base email to match against
current_user_id: The user ID to exclude from the check
Returns:
True if a duplicate is found, False otherwise
"""
regex_pattern = get_base_email_regex_pattern(base_email)
if not regex_pattern:
logger.warning(
f'Could not generate regex pattern for base email: {base_email}'
)
# Fallback to simple matching
for user in users.values():
user_email = user.get('email', '').lower()
if (
user_email
and user.get('id') != current_user_id
and matches_base_email(user_email, base_email)
):
logger.info(
f'Found duplicate email: {user_email} matches base {base_email}'
)
return True
else:
for user in users.values():
user_email = user.get('email', '')
if (
user_email
and user.get('id') != current_user_id
and regex_pattern.match(user_email)
):
logger.info(
f'Found duplicate email: {user_email} matches base {base_email}'
)
return True
return False
@retry(
stop=stop_after_attempt(2),
retry=retry_if_exception_type(KeycloakConnectionError),
before_sleep=_before_sleep_callback,
)
async def check_duplicate_base_email(
self, email: str, current_user_id: str
) -> bool:
"""Check if a user with the same base email already exists.
This method checks for duplicate signups using email + modifier.
It checks if any user exists with the same base email, regardless of whether
the provided email has a + modifier or not.
Examples:
- If email is "joe+test@example.com", it checks for existing users with
base email "joe@example.com" (e.g., "joe@example.com", "joe+1@example.com")
- If email is "joe@example.com", it checks for existing users with
base email "joe@example.com" (e.g., "joe+1@example.com", "joe+test@example.com")
Args:
email: The email address to check (may or may not contain + modifier)
current_user_id: The user ID of the current user (to exclude from check)
Returns:
True if a duplicate is found (excluding current user), False otherwise
"""
if not email:
return False
# We have the option to skip the duplicate email check in test environments
if not DUPLICATE_EMAIL_CHECK:
return False
base_email = extract_base_email(email)
if not base_email:
logger.warning(f'Could not extract base email from: {email}')
return False
try:
local_part, domain = base_email.rsplit('@', 1)
users = await self._query_users_by_wildcard_pattern(local_part, domain)
return self._find_duplicate_in_users(users, base_email, current_user_id)
except KeycloakConnectionError:
logger.exception('KeycloakConnectionError when checking duplicate email')
raise
except Exception as e:
logger.exception(f'Unexpected error checking duplicate email: {e}')
# On any error, allow signup to proceed (fail open)
return False
@retry(
stop=stop_after_attempt(2),
retry=retry_if_exception_type(KeycloakConnectionError),
before_sleep=_before_sleep_callback,
)
async def delete_keycloak_user(self, user_id: str) -> bool:
"""Delete a user from Keycloak.
This method is used to clean up user accounts that were created
but should not exist (e.g., duplicate email signups).
Args:
user_id: The Keycloak user ID to delete
Returns:
True if deletion was successful, False otherwise
"""
try:
keycloak_admin = get_keycloak_admin(self.external)
# Use the sync method (python-keycloak doesn't have async delete_user)
# Run it in a thread executor to avoid blocking the event loop
await asyncio.to_thread(keycloak_admin.delete_user, user_id)
logger.info(f'Successfully deleted Keycloak user {user_id}')
return True
except KeycloakConnectionError:
logger.exception(f'KeycloakConnectionError when deleting user {user_id}')
raise
except KeycloakError as e:
# User might not exist or already deleted
logger.warning(
f'KeycloakError when deleting user {user_id}: {e}',
extra={'user_id': user_id, 'error': str(e)},
)
return False
except Exception as e:
logger.exception(f'Unexpected error deleting Keycloak user {user_id}: {e}')
return False
async def get_user_info_from_user_id(self, user_id: str) -> dict | None:
keycloak_admin = get_keycloak_admin(self.external)
user = await keycloak_admin.a_get_user(user_id)
@@ -740,49 +527,6 @@ class TokenManager:
github_id = github_ids[0]
return github_id
async def disable_keycloak_user(
self, user_id: str, email: str | None = None
) -> None:
"""Disable a Keycloak user account.
Args:
user_id: The Keycloak user ID to disable
email: Optional email address for logging purposes
This method attempts to disable the user account but will not raise exceptions.
Errors are logged but do not prevent the operation from completing.
"""
try:
keycloak_admin = get_keycloak_admin(self.external)
# Get current user to preserve other fields
user = await keycloak_admin.a_get_user(user_id)
if user:
# Update user with enabled=False to disable the account
await keycloak_admin.a_update_user(
user_id=user_id,
payload={
'enabled': False,
'username': user.get('username', ''),
'email': user.get('email', ''),
'emailVerified': user.get('emailVerified', False),
},
)
email_str = f', email: {email}' if email else ''
logger.info(
f'Disabled Keycloak account for user_id: {user_id}{email_str}'
)
else:
logger.warning(
f'User not found in Keycloak when attempting to disable: {user_id}'
)
except Exception as e:
# Log error but don't raise - the caller should handle the blocking regardless
email_str = f', email: {email}' if email else ''
logger.error(
f'Failed to disable Keycloak account for user_id: {user_id}{email_str}: {str(e)}',
exc_info=True,
)
def store_org_token(self, installation_id: int, installation_token: str):
"""Store a GitHub App installation token.

View File

@@ -8,7 +8,8 @@ import socketio
from server.logger import logger
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
from storage.database import session_maker
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from storage.saas_settings_store import SaasSettingsStore
from storage.stored_conversation_metadata import StoredConversationMetadata
from openhands.core.config import LLMConfig
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -524,18 +525,16 @@ class ClusteredConversationManager(StandaloneConversationManager):
)
# Look up the user_id from the database
with session_maker() as session:
conversation_metadata_saas = (
session.query(StoredConversationMetadataSaas)
conversation_metadata = (
session.query(StoredConversationMetadata)
.filter(
StoredConversationMetadataSaas.conversation_id
StoredConversationMetadata.conversation_id
== conversation_id
)
.first()
)
user_id = (
str(conversation_metadata_saas.user_id)
if conversation_metadata_saas
else None
conversation_metadata.user_id if conversation_metadata else None
)
# Handle the stopped conversation asynchronously
asyncio.create_task(
@@ -744,8 +743,6 @@ class ClusteredConversationManager(StandaloneConversationManager):
return
# Restart the agent loop
from storage.saas_settings_store import SaasSettingsStore
config = load_openhands_config()
settings_store = await SaasSettingsStore.get_instance(config, user_id)
settings = await settings_store.load()

View File

@@ -17,7 +17,6 @@ from server.auth.constants import (
GITHUB_APP_PRIVATE_KEY,
GITHUB_APP_WEBHOOK_SECRET,
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from openhands.core.config.utils import load_openhands_config
@@ -188,7 +187,4 @@ class SaaSServerConfig(ServerConfig):
if self.auth_url:
config['AUTH_URL'] = self.auth_url
if RECAPTCHA_SITE_KEY:
config['RECAPTCHA_SITE_KEY'] = RECAPTCHA_SITE_KEY
return config

Some files were not shown because too many files have changed in this diff Show More