Compare commits

..

8 Commits

Author SHA1 Message Date
Chuck Butkus
269e27e734 Use absolute paths for git hooks 2026-03-17 13:31:54 -04:00
Jamie Chicago
79cfffce60 docs: Improve Development.md and CONTRIBUTING.md with OS-specific setup guides (#13432)
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 17:03:33 +01:00
Saurya Velagapudi
b68c75252d Add architecture diagrams explaining system components and WebSocket flow (#12542)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Saurya <saurya@openhands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-17 08:52:40 -07:00
aivong-openhands
d58e12ad74 Fix CVE-2026-27962: Update authlib to 1.6.9 (#13439)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-17 10:13:08 -05:00
Engel Nyst
bd837039dd chore: update skills path comments (#12794) 2026-03-17 10:45:50 -04:00
Kooltek68
8a7779068a docs: fix typo in README.md (#13444) 2026-03-17 10:16:31 -04:00
Neha Prasad
38099934b6 fix : planner PLAN.md rendering and search labels (#13418)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-17 20:59:02 +07:00
Xingyao Wang
75c823c486 feat: expose_secrets param on /users/me + sandbox-scoped secrets API (#13383)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 12:54:57 +00:00
40 changed files with 2198 additions and 351 deletions

View File

@@ -39,6 +39,8 @@ then re-run the command to ensure it passes. Common issues include:
## Repository Structure
Backend:
- Located in the `openhands` directory
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
- For V1 web-app docs, LLM setup should point users to the Settings UI.
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
@@ -342,3 +344,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider
### Sandbox Settings API (SDK Credential Inheritance)
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
never through the SDK client.
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
Auth requirements for `expose_secrets=true`:
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
#### Related SDK code (in `software-agent-sdk` repo):
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`

View File

@@ -1,83 +1,105 @@
# Contributing
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
## Understanding OpenHands's CodeBase
## Our Vision
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
## Setting up Your Development Environment
## Getting Started
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.
### Quick Ways to Contribute
## How Can I Contribute?
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
- **Share OpenHands** with other developers
There are many ways that you can contribute:
### Set Up Your Development Environment
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.
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.
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
- **Quick setup**: `make build`
- **Run locally**: `make run`
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
## What Can I Build?
Full details in our [Development Guide](./Development.md).
Here are a few ways you can help improve the codebase.
### Find Your First Issue
#### UI/UX
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
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.
## Understanding the Codebase
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
to gather consensus from our design team first.
- **[Frontend](./frontend/README.md)** - React application
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
#### Improving the agent
## What Can You Build?
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
### Frontend & UI/UX
- React & TypeScript development
- UI/UX improvements
- Mobile responsiveness
- Component libraries
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
is getting better over time.
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
channel in Slack to learn more.
### Agent Development
- Prompt engineering
- New agent types
- Agent evaluation
- Multi-agent systems
#### Adding a new agent
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
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.
### Backend & Infrastructure
- Python development
- Runtime systems (Docker containers, sandboxes)
- Cloud integrations
- Performance optimization
#### Adding a new runtime
### Testing & Quality Assurance
- Unit testing
- Integration testing
- Bug hunting
- Performance testing
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.
### Documentation & Education
- Technical documentation
- Translation
- Community support
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
## Pull Request Process
#### Testing
### Small Improvements
- Quick review and approval
- Ensure CI tests pass
- Include clear description of changes
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.
### Core Agent Changes
These are evaluated based on:
- **Accuracy** - Does it make the agent better at solving problems?
- **Efficiency** - Does it improve speed or reduce resource usage?
- **Code Quality** - Is the code maintainable and well-tested?
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
## Sending Pull Requests to OpenHands
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
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
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:
### Pull Request Title Format
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
@@ -95,45 +117,16 @@ For example, a PR title could be:
- `refactor: modify package path`
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request Description
### Pull Request description
- Explain what the PR does and why
- Link to related issues
- Include screenshots for UI changes
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
please include a short message that we can add to our changelog
- 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.
## Need Help?
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
please include a short message that we can add to our changelog.
## How to Make Effective Contributions
### 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.
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
### Making Pull Requests
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
#### 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.
#### 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:
1. **Accuracy**
2. **Efficiency**
3. **Code Complexity**
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
- **Slack**: [Join our community](https://openhands.dev/joinslack)
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
- **Email**: contact@openhands.dev

View File

@@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the
on how to clone and setup the project initially before moving on. Otherwise,
you can clone the OpenHands project directly.
## Start the Server for Development
## Choose Your Setup
### 1. Requirements
Select your operating system to see the specific setup instructions:
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
- [Python](https://www.python.org/downloads/) = 3.12
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
- OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
- WSL: netcat => `sudo apt-get install netcat`
- [macOS](#macos-setup)
- [Linux](#linux-setup)
- [Windows WSL](#windows-wsl-setup)
- [Dev Container](#dev-container)
- [Developing in Docker](#developing-in-docker)
- [No sudo access?](#develop-without-sudo-access)
Make sure you have all these dependencies installed before moving on to `make build`.
---
#### Dev container
## macOS Setup
### 1. Install Prerequisites
You'll need the following installed:
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
- **Node.js >= 22** — `brew install node`
- **Poetry >= 1.8** — `brew install poetry`
- **Docker Desktop** — `brew install --cask docker`
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
If you are running headless or CLI workflows, you can prepare local defaults with:
```bash
make setup-config
```
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
---
## Linux Setup
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
### 1. Install Prerequisites
```bash
# Update package list
sudo apt update
# Install system dependencies
sudo apt install -y build-essential curl netcat software-properties-common
# Install Python 3.12
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
# python3.12 --version already works on your system.
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.12 python3.12-dev python3.12-venv
# Install Node.js 22.x
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Install Poetry
curl -sSL https://install.python-poetry.org | python3 -
# Add Poetry to your PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Install Docker
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
# Quick version:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
# Log out and back in for Docker group changes to take effect
```
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
---
## Windows WSL Setup
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
### 1. Install WSL2
**Option A: Windows 11 (Microsoft Store)**
The easiest way on Windows 11:
1. Open the **Microsoft Store** app
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
3. Click **Install**
4. Launch Ubuntu from the Start menu
**Option B: PowerShell**
```powershell
# Run this in PowerShell as Administrator
wsl --install -d Ubuntu-22.04
```
After installation, restart your computer and open Ubuntu.
### 2. Install Prerequisites (in WSL Ubuntu)
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
### 3. Configure Docker for WSL2
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
2. Open Docker Desktop > Settings > General
3. Enable: "Use the WSL 2 based engine"
4. Go to Settings > Resources > WSL Integration
5. Enable integration with your Ubuntu distribution
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
### 4. Build and Setup the Environment
```bash
make build
```
### 5. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
### 6. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
Access the frontend at `http://localhost:3001` from your Windows browser.
---
## Dev Container
There is a [dev container](https://containers.dev/) available which provides a
pre-configured environment with all the necessary dependencies installed if you
@@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the
_Dev Container: Reopen in Container_ command from the Command Palette
(Ctrl+Shift+P).
#### Develop without sudo access
---
## Developing in Docker
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
### Quick Start
```bash
make docker-dev
```
For more details, see the [dev container documentation](./containers/dev/README.md).
### Alternative: Docker Run
If you just want to run OpenHands without setting up a dev environment:
```bash
make docker-run
```
If you don't have `make` installed, run:
```bash
cd ./containers/dev
./dev.sh
```
---
## Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
`conda` or `mamba` to manage the packages for you:
@@ -48,159 +253,90 @@ mamba install conda-forge::nodejs
mamba install conda-forge::poetry
```
### 2. Build and Setup The Environment
---
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
that OpenHands is ready to run on your system:
## Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself!
### Quick Start
```bash
make build
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
### 3. Configuring the Language Model
Access the interface at:
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For external access:
```bash
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
To configure the LM of your choice, run:
---
## LLM Debugging
If you encounter issues with the Language Model, enable debug logging:
```bash
make setup-config
export DEBUG=1
# Restart the backend
make start-backend
```
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
---
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
## Testing
### 4. Running the application
#### Option A: Run the Full Application
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
```bash
make run
```
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
```bash
make start-frontend
```
### 5. Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
#### Quick Start
1. **Build and run OpenHands:**
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
2. **Access the interface:**
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
3. **Configure for external access (if needed):**
```bash
# For external access (e.g., cloud environments)
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
### 6. LLM Debugging
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
### 7. Help
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
```bash
make help
```
### 8. Testing
To run tests, refer to the following:
#### Unit tests
### Unit Tests
```bash
poetry run pytest ./tests/unit/test_*.py
```
### 9. Add or update dependency
---
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
## Adding Dependencies
### 10. Use existing Docker image
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
2. Update the lock file: `poetry lock --no-update`
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`
## Using Existing Docker Images
## Develop inside Docker container
TL;DR
To reduce build time, you can use an existing runtime image:
```bash
make docker-dev
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
See more details [here](./containers/dev/README.md).
---
If you are just interested in running `OpenHands` without installing all the required tools on your host.
## Help
```bash
make docker-run
make help
```
If you do not have `make` on your host, run:
```bash
cd ./containers/dev
./dev.sh
```
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
---
## Key Documentation Resources
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
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
- [/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
- [/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

@@ -51,6 +51,6 @@ 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 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)
- 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 completely 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.

View File

@@ -0,0 +1,13 @@
# Enterprise Architecture Documentation
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
## Documentation
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
## Related Documentation
For core OpenHands architecture (applicable to all deployments), see:
- [Core Architecture Documentation](../../../openhands/architecture/README.md)

View File

@@ -0,0 +1,58 @@
# Authentication Flow (SaaS Deployment)
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant App as App Server
participant KC as Keycloak
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
participant DB as User Database
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
User->>App: Access OpenHands
App->>User: Redirect to Keycloak
User->>KC: Login request
KC->>User: Show login options
User->>KC: Select provider (e.g., GitHub)
KC->>IdP: OAuth redirect
User->>IdP: Authenticate
IdP-->>KC: OAuth callback + tokens
Note over KC: Create/update user session
KC-->>User: Redirect with auth code
User->>App: Auth code
App->>KC: Exchange code for tokens
KC-->>App: Access token + Refresh token
Note over App: Create signed JWT cookie
App->>DB: Store/update user record
App-->>User: Set keycloak_auth cookie
Note over User,DB: Subsequent Requests
User->>App: Request with cookie
Note over App: Verify JWT signature
App->>KC: Validate token (if needed)
KC-->>App: Token valid
Note over App: Extract user context
App-->>User: Authorized response
```
### Authentication Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Keycloak** | Identity provider, SSO, token management | External service |
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
### Token Flow
1. **Keycloak Access Token**: Short-lived token for API access
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)

View File

@@ -0,0 +1,88 @@
# External Integrations
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
```mermaid
sequenceDiagram
autonumber
participant Ext as External Service<br/>(GitHub/Slack/Jira)
participant App as App Server
participant IntRouter as Integration Router
participant Manager as Integration Manager
participant Conv as Conversation Service
participant Sandbox as Sandbox
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
Ext->>App: POST /api/integration/{service}/events
App->>IntRouter: Route to service handler
Note over IntRouter: Verify signature (HMAC)
IntRouter->>Manager: Parse event payload
Note over Manager: Extract context (repo, issue, user)
Note over Manager: Map external user → OpenHands user
Manager->>Conv: Create conversation (with issue context)
Conv->>Sandbox: Provision sandbox
Sandbox-->>Conv: Ready
Manager->>Sandbox: Start agent with task
Note over Ext,Sandbox: Agent Works on Task...
Sandbox-->>Manager: Task complete
Manager->>Ext: POST result<br/>(PR, comment, etc.)
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
App->>Manager: Process callback
Manager->>Ext: Update external service
```
### Supported Integrations
| Integration | Trigger Events | Agent Actions |
|-------------|----------------|---------------|
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
| **Slack** | @mention in channel | Reply in thread, create tasks |
| **Jira** | Issue created/updated | Update ticket, add comments |
| **Linear** | Issue created | Update status, add comments |
### Integration Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
### Integration Authentication
```
External Service (e.g., GitHub)
┌─────────────────────────────────┐
│ GitHub App Installation │
│ - Webhook secret for signature │
│ - App private key for API calls │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ User Account Linking │
│ - Keycloak user ID │
│ - GitHub user ID │
│ - Stored OAuth tokens │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Agent Execution │
│ - Uses linked tokens for API │
│ - Can push, create PRs, comment │
└─────────────────────────────────┘
```

View File

@@ -60,7 +60,9 @@ class ResolverUserContext(UserContext):
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:

View File

@@ -602,14 +602,14 @@ files = [
[[package]]
name = "authlib"
version = "1.6.7"
version = "1.6.9"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
]
[package.dependencies]

View File

@@ -28,6 +28,7 @@ const createUserMessageEvent = (id: string): MessageEvent => ({
const createPlanningObservationEvent = (
id: string,
actionId: string = "action-1",
path: string = "/workspace/PLAN.md",
): ObservationEvent<PlanningFileEditorObservation> => ({
id,
timestamp: new Date().toISOString(),
@@ -40,7 +41,7 @@ const createPlanningObservationEvent = (
content: [{ type: "text", text: "Plan content" }],
is_error: false,
command: "create",
path: "/workspace/PLAN.md",
path,
prev_exist: false,
old_content: null,
new_content: "Plan content",
@@ -172,6 +173,31 @@ describe("usePlanPreviewEvents", () => {
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(true);
});
it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(0);
});
it("should include only Plan.md observations when mixed with other file edits", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-2")).toBe(true);
});
});
describe("shouldShowPlanPreview", () => {

View File

@@ -21,6 +21,8 @@ import {
BrowserListTabsAction,
BrowserSwitchTabAction,
BrowserCloseTabAction,
GlobAction,
GrepAction,
} from "#/types/v1/core/base/action";
const getRiskText = (risk: SecurityRisk) => {
@@ -39,6 +41,28 @@ const getRiskText = (risk: SecurityRisk) => {
const getNoContentActionContent = (): string => "";
// Grep/Glob search actions
const getSearchActionContent = (
event: ActionEvent<GlobAction | GrepAction>,
): string => {
const { action } = event;
const parts: string[] = [];
if (action.pattern) {
parts.push(`**Pattern:** \`${action.pattern}\``);
}
if (action.path) {
parts.push(`**Path:** \`${action.path}\``);
}
if ("include" in action && action.include) {
parts.push(`**Include:** \`${action.include}\``);
}
const { summary } = event as { summary?: string };
if (summary) {
parts.push(`**Summary:** ${summary}`);
}
return parts.length > 0 ? parts.join("\n") : getNoContentActionContent();
};
// File Editor Actions
const getFileEditorActionContent = (
action: FileEditorAction | StrReplaceEditorAction,
@@ -228,6 +252,12 @@ export const getActionContent = (event: ActionEvent): string => {
case "BrowserCloseTabAction":
return getBrowserActionContent(action);
case "GrepAction":
case "GlobAction":
return getSearchActionContent(
event as ActionEvent<GlobAction | GrepAction>,
);
default:
return getDefaultEventContent(event);
}

View File

@@ -84,6 +84,24 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
case "TaskTrackerAction":
actionKey = "ACTION_MESSAGE$TASK_TRACKING";
break;
case "GrepAction":
actionKey = "ACTION_MESSAGE$GREP";
actionValues = {
pattern:
"pattern" in event.action && event.action.pattern
? trimText(String(event.action.pattern), 50)
: "",
};
break;
case "GlobAction":
actionKey = "ACTION_MESSAGE$GLOB";
actionValues = {
pattern:
"pattern" in event.action && event.action.pattern
? trimText(String(event.action.pattern), 50)
: "",
};
break;
case "BrowserNavigateAction":
case "BrowserClickAction":
case "BrowserTypeAction":

View File

@@ -38,19 +38,24 @@ function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] {
return phases;
}
const isPlanFilePath = (path: string | null): boolean =>
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
/**
* Finds the last PlanningFileEditorObservation in a phase.
* Finds the last PlanningFileEditorObservation for Plan.md in a phase.
*
* @param phase - Array of events in a phase
* @returns The event ID of the last PlanningFileEditorObservation, or null
* @returns The event ID of the last Plan.md observation, or null
*/
function findLastPlanningObservationInPhase(
phase: OpenHandsEvent[],
): string | null {
// Iterate backwards to find the last one
for (let i = phase.length - 1; i >= 0; i -= 1) {
const event = phase[i];
if (isPlanningFileEditorObservationEvent(event)) {
if (
isPlanningFileEditorObservationEvent(event) &&
isPlanFilePath(event.observation.path)
) {
return event.id;
}
}

View File

@@ -126,13 +126,15 @@ export function ConversationWebSocketProvider({
const receivedEventCountRefMain = useRef(0);
const receivedEventCountRefPlanning = useRef(0);
// Track the latest PlanningFileEditorObservation event during history replay
// We'll only call the API once after history loading completes
// Track the latest PlanningFileEditorObservation for Plan.md during history replay
const latestPlanningFileEventRef = useRef<{
path: string;
conversationId: string;
} | null>(null);
const isPlanFilePath = (path: string | null): boolean =>
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
// Helper function to update metrics from stats event
const updateMetricsFromStats = useCallback(
(event: ConversationStateUpdateEventStats) => {
@@ -612,37 +614,39 @@ export function ConversationWebSocketProvider({
appendOutput(textContent);
}
// Handle PlanningFileEditorObservation events - read and update plan content
// Handle PlanningFileEditorObservation - only update plan for Plan.md
if (isPlanningFileEditorObservationEvent(event)) {
const planningAgentConversation = subConversations?.[0];
const planningConversationId = planningAgentConversation?.id;
const { path } = event.observation;
if (isPlanFilePath(path)) {
const planningAgentConversation = subConversations?.[0];
const planningConversationId = planningAgentConversation?.id;
if (planningConversationId && event.observation.path) {
// During history replay, track the latest event but don't call API
// After history loading completes, we'll call the API once with the latest event
if (isLoadingHistoryPlanning) {
latestPlanningFileEventRef.current = {
path: event.observation.path,
conversationId: planningConversationId,
};
} else {
// History loading is complete - this is a new real-time event
// Call the API immediately for real-time updates
readConversationFile(
{
if (planningConversationId && path) {
if (isLoadingHistoryPlanning) {
latestPlanningFileEventRef.current = {
path,
conversationId: planningConversationId,
filePath: event.observation.path,
},
{
onSuccess: (fileContent) => {
setPlanContent(fileContent);
};
} else {
readConversationFile(
{
conversationId: planningConversationId,
filePath: path,
},
onError: (error) => {
// eslint-disable-next-line no-console
console.warn("Failed to read conversation file:", error);
{
onSuccess: (fileContent) => {
setPlanContent(fileContent);
},
onError: (error) => {
// eslint-disable-next-line no-console
console.warn(
"Failed to read conversation file:",
error,
);
},
},
},
);
);
}
}
}
}

View File

@@ -533,6 +533,8 @@ export enum I18nKey {
ACTION_MESSAGE$SYSTEM = "ACTION_MESSAGE$SYSTEM",
ACTION_MESSAGE$CONDENSATION = "ACTION_MESSAGE$CONDENSATION",
ACTION_MESSAGE$TASK_TRACKING = "ACTION_MESSAGE$TASK_TRACKING",
ACTION_MESSAGE$GREP = "ACTION_MESSAGE$GREP",
ACTION_MESSAGE$GLOB = "ACTION_MESSAGE$GLOB",
OBSERVATION_MESSAGE$RUN = "OBSERVATION_MESSAGE$RUN",
OBSERVATION_MESSAGE$RUN_IPYTHON = "OBSERVATION_MESSAGE$RUN_IPYTHON",
OBSERVATION_MESSAGE$READ = "OBSERVATION_MESSAGE$READ",

View File

@@ -8527,6 +8527,38 @@
"tr": "Görevleri yönetiyor",
"uk": "Керування завданнями"
},
"ACTION_MESSAGE$GREP": {
"en": "Search in files: <cmd>{{pattern}}</cmd>",
"ja": "ファイル内検索: <cmd>{{pattern}}</cmd>",
"zh-CN": "在文件中搜索: <cmd>{{pattern}}</cmd>",
"zh-TW": "在檔案中搜尋: <cmd>{{pattern}}</cmd>",
"ko-KR": "파일 내 검색: <cmd>{{pattern}}</cmd>",
"no": "Søk i filer: <cmd>{{pattern}}</cmd>",
"it": "Cerca nei file: <cmd>{{pattern}}</cmd>",
"pt": "Pesquisar em arquivos: <cmd>{{pattern}}</cmd>",
"es": "Buscar en archivos: <cmd>{{pattern}}</cmd>",
"ar": "البحث داخل الملفات: <cmd>{{pattern}}</cmd>",
"fr": "Rechercher dans les fichiers: <cmd>{{pattern}}</cmd>",
"tr": "Dosyalarda ara: <cmd>{{pattern}}</cmd>",
"de": "In Dateien suchen: <cmd>{{pattern}}</cmd>",
"uk": "Пошук у файлах: <cmd>{{pattern}}</cmd>"
},
"ACTION_MESSAGE$GLOB": {
"en": "Search files: <cmd>{{pattern}}</cmd>",
"ja": "ファイル検索: <cmd>{{pattern}}</cmd>",
"zh-CN": "搜索文件: <cmd>{{pattern}}</cmd>",
"zh-TW": "搜尋檔案: <cmd>{{pattern}}</cmd>",
"ko-KR": "파일 검색: <cmd>{{pattern}}</cmd>",
"no": "Søk filer: <cmd>{{pattern}}</cmd>",
"it": "Cerca file: <cmd>{{pattern}}</cmd>",
"pt": "Pesquisar arquivos: <cmd>{{pattern}}</cmd>",
"es": "Buscar archivos: <cmd>{{pattern}}</cmd>",
"ar": "البحث في الملفات: <cmd>{{pattern}}</cmd>",
"fr": "Rechercher des fichiers: <cmd>{{pattern}}</cmd>",
"tr": "Dosya ara: <cmd>{{pattern}}</cmd>",
"de": "Dateien suchen: <cmd>{{pattern}}</cmd>",
"uk": "Пошук файлів: <cmd>{{pattern}}</cmd>"
},
"OBSERVATION_MESSAGE$RUN": {
"en": "Ran <cmd>{{command}}</cmd>",
"zh-CN": "运行 <cmd>{{command}}</cmd>",
@@ -8672,36 +8704,36 @@
"uk": "Думка"
},
"OBSERVATION_MESSAGE$GLOB": {
"en": "Glob: <cmd>{{pattern}}</cmd>",
"ja": "Glob: <cmd>{{pattern}}</cmd>",
"zh-CN": "Glob: <cmd>{{pattern}}</cmd>",
"zh-TW": "Glob: <cmd>{{pattern}}</cmd>",
"ko-KR": "Glob: <cmd>{{pattern}}</cmd>",
"no": "Glob: <cmd>{{pattern}}</cmd>",
"it": "Glob: <cmd>{{pattern}}</cmd>",
"pt": "Glob: <cmd>{{pattern}}</cmd>",
"es": "Glob: <cmd>{{pattern}}</cmd>",
"ar": "Glob: <cmd>{{pattern}}</cmd>",
"fr": "Glob: <cmd>{{pattern}}</cmd>",
"tr": "Glob: <cmd>{{pattern}}</cmd>",
"de": "Glob: <cmd>{{pattern}}</cmd>",
"uk": "Glob: <cmd>{{pattern}}</cmd>"
"en": "Search files: <cmd>{{pattern}}</cmd>",
"ja": "ファイル検索: <cmd>{{pattern}}</cmd>",
"zh-CN": "搜索文件: <cmd>{{pattern}}</cmd>",
"zh-TW": "搜尋檔案: <cmd>{{pattern}}</cmd>",
"ko-KR": "파일 검색: <cmd>{{pattern}}</cmd>",
"no": "Søk filer: <cmd>{{pattern}}</cmd>",
"it": "Cerca file: <cmd>{{pattern}}</cmd>",
"pt": "Pesquisar arquivos: <cmd>{{pattern}}</cmd>",
"es": "Buscar archivos: <cmd>{{pattern}}</cmd>",
"ar": "البحث في الملفات: <cmd>{{pattern}}</cmd>",
"fr": "Rechercher des fichiers: <cmd>{{pattern}}</cmd>",
"tr": "Dosya ara: <cmd>{{pattern}}</cmd>",
"de": "Dateien suchen: <cmd>{{pattern}}</cmd>",
"uk": "Пошук файлів: <cmd>{{pattern}}</cmd>"
},
"OBSERVATION_MESSAGE$GREP": {
"en": "Grep: <cmd>{{pattern}}</cmd>",
"ja": "Grep: <cmd>{{pattern}}</cmd>",
"zh-CN": "Grep: <cmd>{{pattern}}</cmd>",
"zh-TW": "Grep: <cmd>{{pattern}}</cmd>",
"ko-KR": "Grep: <cmd>{{pattern}}</cmd>",
"no": "Grep: <cmd>{{pattern}}</cmd>",
"it": "Grep: <cmd>{{pattern}}</cmd>",
"pt": "Grep: <cmd>{{pattern}}</cmd>",
"es": "Grep: <cmd>{{pattern}}</cmd>",
"ar": "Grep: <cmd>{{pattern}}</cmd>",
"fr": "Grep: <cmd>{{pattern}}</cmd>",
"tr": "Grep: <cmd>{{pattern}}</cmd>",
"de": "Grep: <cmd>{{pattern}}</cmd>",
"uk": "Grep: <cmd>{{pattern}}</cmd>"
"en": "Search in files: <cmd>{{pattern}}</cmd>",
"ja": "ファイル内検索: <cmd>{{pattern}}</cmd>",
"zh-CN": "在文件中搜索: <cmd>{{pattern}}</cmd>",
"zh-TW": "在檔案中搜尋: <cmd>{{pattern}}</cmd>",
"ko-KR": "파일 내 검색: <cmd>{{pattern}}</cmd>",
"no": "Søk i filer: <cmd>{{pattern}}</cmd>",
"it": "Cerca nei file: <cmd>{{pattern}}</cmd>",
"pt": "Pesquisar em arquivos: <cmd>{{pattern}}</cmd>",
"es": "Buscar en archivos: <cmd>{{pattern}}</cmd>",
"ar": "البحث داخل الملفات: <cmd>{{pattern}}</cmd>",
"fr": "Rechercher dans les fichiers: <cmd>{{pattern}}</cmd>",
"tr": "Dosyalarda ara: <cmd>{{pattern}}</cmd>",
"de": "In Dateien suchen: <cmd>{{pattern}}</cmd>",
"uk": "Пошук у файлах: <cmd>{{pattern}}</cmd>"
},
"OBSERVATION_MESSAGE$TASK_TRACKING_PLAN": {
"en": "Agent updated the plan",

View File

@@ -244,6 +244,32 @@ export interface PlanningFileEditorAction extends ActionBase<"PlanningFileEditor
view_range: [number, number] | null;
}
export interface GlobAction extends ActionBase<"GlobAction"> {
/**
* The glob pattern to match files against.
*/
pattern: string;
/**
* The directory to search in.
*/
path: string | null;
}
export interface GrepAction extends ActionBase<"GrepAction"> {
/**
* The regex pattern to search for in file contents.
*/
pattern: string;
/**
* The file or directory to search in.
*/
path: string | null;
/**
* Glob pattern to filter files.
*/
include: string | null;
}
export type Action =
| MCPToolAction
| FinishAction
@@ -263,4 +289,6 @@ export type Action =
| BrowserGoBackAction
| BrowserListTabsAction
| BrowserSwitchTabAction
| BrowserCloseTabAction;
| BrowserCloseTabAction
| GlobAction
| GrepAction;

View File

@@ -23,7 +23,11 @@ type ActionOnlyType =
type ObservationOnlyType = "Browser";
type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`;
type ActionEventType =
| `${ActionOnlyType}Action`
| `${EventType}Action`
| "GlobAction"
| "GrepAction";
type ObservationEventType =
| `${ObservationOnlyType}Observation`
| `${EventType}Observation`

View File

@@ -1,8 +1,14 @@
# OpenHands Architecture
This directory contains the core components of OpenHands.
For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture).
## Documentation
**[Architecture Documentation](./architecture/README.md)** with diagrams covering:
- System Architecture Overview
- Conversation Startup & WebSocket Flow
- Agent Execution & LLM Flow
- **[External Architecture Docs](https://docs.openhands.dev/usage/architecture/backend)** - Official documentation (v0 backend architecture)
## Classes

View File

@@ -583,7 +583,7 @@ async def get_conversation_skills(
- Global skills (OpenHands/skills/)
- User skills (~/.openhands/skills/)
- Organization skills (org/.openhands repository)
- Repository skills (repo/.openhands/skills/ or .openhands/microagents/)
- Repository skills (repo .agents/skills/, .openhands/microagents/, and legacy .openhands/skills/)
Returns:
JSONResponse: A JSON response containing the list of skills.

View File

@@ -43,8 +43,8 @@ from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
PRE_COMMIT_HOOK = '/.git/hooks/pre-commit'
PRE_COMMIT_LOCAL = '/.git/hooks/pre-commit.local'
def get_project_dir(
@@ -105,7 +105,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
- Public skills (from OpenHands/skills GitHub repo)
- User skills (from ~/.openhands/skills/)
- Organization skills (from {org}/.openhands repo)
- Project/repo skills (from workspace .openhands/skills/)
- Project/repo skills (from repo .agents/skills/, .openhands/microagents/, and legacy .openhands/skills/)
- Sandbox skills (from exposed URLs)
Args:

View File

@@ -7,7 +7,7 @@ import zipfile
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, AsyncGenerator, Sequence
from typing import Any, AsyncGenerator, Sequence, cast
from uuid import UUID, uuid4
import httpx
@@ -84,7 +84,7 @@ from openhands.app_server.utils.llm_metadata import (
get_llm_metadata,
should_set_litellm_extra_body,
)
from openhands.integrations.provider import ProviderType
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import SuggestedTask
from openhands.sdk import Agent, AgentContext, LocalWorkspace
from openhands.sdk.hooks import HookConfig
@@ -837,7 +837,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
secrets = await self.user_context.get_secrets()
# Get all provider tokens from user authentication
provider_tokens = await self.user_context.get_provider_tokens()
provider_tokens = cast(
PROVIDER_TOKEN_TYPE | None,
await self.user_context.get_provider_tokens(),
)
if not provider_tokens:
return secrets

View File

@@ -59,3 +59,20 @@ class SandboxInfo(BaseModel):
class SandboxPage(BaseModel):
items: list[SandboxInfo]
next_page_id: str | None = None
class SecretNameItem(BaseModel):
"""A secret's name and optional description (value NOT included)."""
name: str = Field(description='The secret name/key')
description: str | None = Field(
default=None, description='Optional description of the secret'
)
class SecretNamesResponse(BaseModel):
"""Response listing available secret names (no raw values)."""
secrets: list[SecretNameItem] = Field(
default_factory=list, description='Available secrets'
)

View File

@@ -1,16 +1,30 @@
"""Runtime Containers router for OpenHands App Server."""
from typing import Annotated
import logging
from typing import Annotated, cast
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.security import APIKeyHeader
from openhands.agent_server.models import Success
from openhands.app_server.config import depends_sandbox_service
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxPage
from openhands.app_server.sandbox.sandbox_models import (
SandboxInfo,
SandboxPage,
SecretNameItem,
SecretNamesResponse,
)
from openhands.app_server.sandbox.sandbox_service import (
SandboxService,
)
from openhands.app_server.sandbox.session_auth import validate_session_key
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.server.dependencies import get_dependencies
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
_logger = logging.getLogger(__name__)
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware
@@ -94,3 +108,104 @@ async def delete_sandbox(
if not exists:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return Success()
# ---------------------------------------------------------------------------
# Sandbox-scoped secrets (authenticated via X-Session-API-Key)
# ---------------------------------------------------------------------------
async def _valid_sandbox_from_session_key(
request: Request,
sandbox_id: str,
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
) -> SandboxInfo:
"""Authenticate via ``X-Session-API-Key`` and verify sandbox ownership."""
sandbox_info = await validate_session_key(session_api_key)
if sandbox_info.id != sandbox_id:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail='Session API key does not match sandbox',
)
return sandbox_info
async def _get_user_context(sandbox_info: SandboxInfo) -> AuthUserContext:
"""Build an ``AuthUserContext`` for the sandbox owner."""
if not sandbox_info.created_by_user_id:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='Sandbox has no associated user',
)
user_auth = await get_user_auth_for_user(sandbox_info.created_by_user_id)
return AuthUserContext(user_auth=user_auth)
@router.get('/{sandbox_id}/settings/secrets')
async def list_secret_names(
sandbox_info: SandboxInfo = Depends(_valid_sandbox_from_session_key),
) -> SecretNamesResponse:
"""List available secret names (no raw values).
Includes both custom secrets and provider tokens (e.g. github_token).
"""
user_context = await _get_user_context(sandbox_info)
items: list[SecretNameItem] = []
# Custom secrets
secret_sources = await user_context.get_secrets()
for name, source in secret_sources.items():
items.append(SecretNameItem(name=name, description=source.description))
# Provider tokens (github_token, gitlab_token, etc.)
provider_env_vars = cast(
dict[str, str] | None,
await user_context.get_provider_tokens(as_env_vars=True),
)
if provider_env_vars:
for env_key in provider_env_vars:
items.append(
SecretNameItem(name=env_key, description=f'{env_key} provider token')
)
return SecretNamesResponse(secrets=items)
@router.get('/{sandbox_id}/settings/secrets/{secret_name}')
async def get_secret_value(
secret_name: str,
sandbox_info: SandboxInfo = Depends(_valid_sandbox_from_session_key),
) -> Response:
"""Return a single secret value as plain text.
Called by ``LookupSecret`` inside the sandbox. Checks custom secrets
first, then falls back to provider tokens — always resolving the
latest token at call time.
"""
user_context = await _get_user_context(sandbox_info)
# Check custom secrets first
secret_sources = await user_context.get_secrets()
source = secret_sources.get(secret_name)
if source is not None:
value = source.get_value()
if value is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Secret has no value')
return Response(content=value, media_type='text/plain')
# Fall back to provider tokens (resolved fresh per request)
provider_env_vars = cast(
dict[str, str] | None,
await user_context.get_provider_tokens(as_env_vars=True),
)
if provider_env_vars:
token_value = provider_env_vars.get(secret_name)
if token_value is not None:
return Response(content=token_value, media_type='text/plain')
raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Secret not found')

View File

@@ -0,0 +1,66 @@
"""Shared session-key authentication for sandbox-scoped endpoints.
Both the sandbox router and the user router need to validate
``X-Session-API-Key`` headers. This module centralises that logic so
it lives in exactly one place.
The ``InjectorState`` + ``ADMIN`` pattern used here is established in
``webhook_router.py`` — the sandbox service requires an admin context to
look up sandboxes across all users by session key, but the session key
itself acts as the proof of access.
"""
import logging
from fastapi import HTTPException, status
from openhands.app_server.config import get_global_config, get_sandbox_service
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import ADMIN, USER_CONTEXT_ATTR
from openhands.server.types import AppMode
_logger = logging.getLogger(__name__)
async def validate_session_key(session_api_key: str | None) -> SandboxInfo:
"""Validate an ``X-Session-API-Key`` and return the associated sandbox.
Raises:
HTTPException(401): if the key is missing or does not map to a sandbox.
HTTPException(401): in SAAS mode if the sandbox has no owning user.
"""
if not session_api_key:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='X-Session-API-Key header is required',
)
# The sandbox service is scoped to users. To look up a sandbox by session
# key (which could belong to *any* user) we need an admin context. This
# is the same pattern used in webhook_router.valid_sandbox().
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with get_sandbox_service(state) as sandbox_service:
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
session_api_key
)
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
)
if not sandbox_info.created_by_user_id:
if get_global_config().app_mode == AppMode.SAAS:
_logger.error(
'Sandbox had no user specified',
extra={'sandbox_id': sandbox_info.id},
)
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='Sandbox had no user specified',
)
return sandbox_info

View File

@@ -48,8 +48,27 @@ class AuthUserContext(UserContext):
self._user_info = user_info
return user_info
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.user_auth.get_provider_tokens()
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
"""Return provider tokens.
Args:
as_env_vars: When True, return a ``dict[str, str]`` mapping env
var names (e.g. ``github_token``) to plain-text token values,
resolving the latest value at call time. When False (default),
return the raw ``dict[ProviderType, ProviderToken]``.
"""
provider_tokens = await self.user_auth.get_provider_tokens()
if not as_env_vars:
return provider_tokens
results: dict[str, str] = {}
if provider_tokens:
for provider_type, provider_token in provider_tokens.items():
if provider_token.token:
env_key = ProviderHandler.get_provider_env_key(provider_type)
results[env_key] = provider_token.token.get_secret_value()
return results
async def get_provider_handler(self):
provider_handler = self._provider_handler
@@ -79,9 +98,9 @@ class AuthUserContext(UserContext):
return token
async def get_secrets(self) -> dict[str, SecretSource]:
results = {}
results: dict[str, SecretSource] = {}
# Include custom secrets...
# Include custom secrets
secrets = await self.user_auth.get_secrets()
if secrets:
for name, custom_secret in secrets.custom_secrets.items():

View File

@@ -26,7 +26,9 @@ class SpecifyUserContext(UserContext):
) -> str:
raise NotImplementedError()
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
raise NotImplementedError()
async def get_latest_token(self, provider_type: ProviderType) -> str | None:

View File

@@ -35,8 +35,16 @@ class UserContext(ABC):
"""
@abstractmethod
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
"""Get the latest tokens for all provider types"""
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
"""Get the latest tokens for all provider types.
Args:
as_env_vars: When True, return a ``dict[str, str]`` mapping env
var names (e.g. ``github_token``) to plain-text token values.
When False (default), return the raw provider token mapping.
"""
@abstractmethod
async def get_latest_token(self, provider_type: ProviderType) -> str | None:

View File

@@ -1,12 +1,18 @@
"""User router for OpenHands App Server. For the moment, this simply implements the /me endpoint."""
from fastapi import APIRouter, HTTPException, status
import logging
from fastapi import APIRouter, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from openhands.app_server.config import depends_user_context
from openhands.app_server.sandbox.session_auth import validate_session_key
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.server.dependencies import get_dependencies
_logger = logging.getLogger(__name__)
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware
router = APIRouter(prefix='/users', tags=['User'], dependencies=get_dependencies())
@@ -18,9 +24,52 @@ user_dependency = depends_user_context()
@router.get('/me')
async def get_current_user(
user_context: UserContext = user_dependency,
expose_secrets: bool = Query(
default=False,
description='If true, return unmasked secret values (e.g. llm_api_key). '
'Requires a valid X-Session-API-Key header for an active sandbox '
'owned by the authenticated user.',
),
x_session_api_key: str | None = Header(default=None),
) -> UserInfo:
"""Get the current authenticated user."""
user = await user_context.get_user_info()
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
if expose_secrets:
await _validate_session_key_ownership(user_context, x_session_api_key)
return JSONResponse( # type: ignore[return-value]
content=user.model_dump(mode='json', context={'expose_secrets': True})
)
return user
async def _validate_session_key_ownership(
user_context: UserContext,
session_api_key: str | None,
) -> None:
"""Verify the session key belongs to a sandbox owned by the caller.
Raises ``HTTPException`` if the key is missing, invalid, or belongs
to a sandbox owned by a different user.
"""
sandbox_info = await validate_session_key(session_api_key)
# Verify the sandbox is owned by the authenticated user.
caller_id = await user_context.get_user_id()
if not caller_id:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='Cannot determine authenticated user',
)
if sandbox_info.created_by_user_id != caller_id:
_logger.warning(
'Session key user mismatch: sandbox owner=%s, caller=%s',
sandbox_info.created_by_user_id,
caller_id,
)
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail='Session API key does not belong to the authenticated user',
)

View File

@@ -0,0 +1,10 @@
# OpenHands Architecture
Architecture diagrams and explanations for the OpenHands system.
## Documentation Sections
- [System Architecture Overview](./system-architecture.md) - Multi-tier architecture and component responsibilities
- [Conversation Startup & WebSocket Flow](./conversation-startup.md) - Runtime provisioning and real-time communication
- [Agent Execution & LLM Flow](./agent-execution.md) - LLM integration and action execution loop
- [Observability](./observability.md) - Logging, metrics, and monitoring

View File

@@ -0,0 +1,92 @@
# Agent Execution & LLM Flow
When the agent executes inside the sandbox, it makes LLM calls through LiteLLM:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant AS as Agent Server
participant Agent as Agent<br/>(CodeAct)
participant LLM as LLM Class
participant Lite as LiteLLM
participant Proxy as LLM Proxy<br/>(llm-proxy.app.all-hands.dev)
participant Provider as LLM Provider<br/>(OpenAI, Anthropic, etc.)
participant AES as Action Execution Server
Note over User,AES: Agent Loop - LLM Call Flow
User->>AS: WebSocket: User message
AS->>Agent: Process message
Note over Agent: Build prompt from state
Agent->>LLM: completion(messages, tools)
Note over LLM: Apply config (model, temp, etc.)
alt Using OpenHands Provider
LLM->>Lite: litellm_proxy/{model}
Lite->>Proxy: POST /chat/completions
Note over Proxy: Auth, rate limit, routing
Proxy->>Provider: Forward request
Provider-->>Proxy: Response
Proxy-->>Lite: Response
else Using Direct Provider
LLM->>Lite: {provider}/{model}
Lite->>Provider: Direct API call
Provider-->>Lite: Response
end
Lite-->>LLM: ModelResponse
Note over LLM: Track metrics (cost, tokens)
LLM-->>Agent: Parsed response
Note over Agent: Parse action from response
AS->>User: WebSocket: Action event
Note over User,AES: Action Execution
AS->>AES: HTTP: Execute action
Note over AES: Run command/edit file
AES-->>AS: Observation
AS->>User: WebSocket: Observation event
Note over Agent: Update state
Note over Agent: Loop continues...
```
### LLM Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **LLM Class** | Wrapper with retries, metrics, config | `openhands/llm/llm.py` |
| **LiteLLM** | Universal LLM API adapter | External library |
| **LLM Proxy** | OpenHands managed proxy for billing/routing | `llm-proxy.app.all-hands.dev` |
| **LLM Registry** | Manages multiple LLM instances | `openhands/llm/llm_registry.py` |
### Model Routing
```
User selects model
┌───────────────────┐
│ Model prefix? │
└───────────────────┘
├── openhands/claude-3-5 ──► Rewrite to litellm_proxy/claude-3-5
│ Base URL: llm-proxy.app.all-hands.dev
├── anthropic/claude-3-5 ──► Direct to Anthropic API
│ (User's API key)
├── openai/gpt-4 ──► Direct to OpenAI API
│ (User's API key)
└── azure/gpt-4 ──► Direct to Azure OpenAI
(User's API key + endpoint)
```
### LLM Proxy
When using `openhands/` prefixed models, requests are routed through a managed proxy.
See the [OpenHands documentation](https://docs.openhands.dev/) for details on supported models.

View File

@@ -0,0 +1,68 @@
# Conversation Startup & WebSocket Flow
When a user starts a conversation, this sequence occurs:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant App as App Server
participant SS as Sandbox Service
participant RAPI as Runtime API
participant Pool as Warm Pool
participant Sandbox as Sandbox (Container)
participant AS as Agent Server
participant AES as Action Execution Server
Note over User,AES: Phase 1: Conversation Creation
User->>App: POST /api/conversations
Note over App: Authenticate user
App->>SS: Create sandbox
Note over SS,Pool: Phase 2: Runtime Provisioning
SS->>RAPI: POST /start (image, env, config)
RAPI->>Pool: Check for warm runtime
alt Warm runtime available
Pool-->>RAPI: Return warm runtime
Note over RAPI: Assign to session
else No warm runtime
RAPI->>Sandbox: Create new container
Sandbox->>AS: Start Agent Server
Sandbox->>AES: Start Action Execution Server
AES-->>AS: Ready
end
RAPI-->>SS: Runtime URL + session API key
SS-->>App: Sandbox info
App-->>User: Conversation ID + Sandbox URL
Note over User,AES: Phase 3: Direct WebSocket Connection
User->>AS: WebSocket: /sockets/events/{id}
AS-->>User: Connection accepted
AS->>User: Replay historical events
Note over User,AES: Phase 4: User Sends Message
User->>AS: WebSocket: SendMessageRequest
Note over AS: Agent processes message
Note over AS: LLM call → generate action
Note over User,AES: Phase 5: Action Execution Loop
loop Agent Loop
AS->>AES: HTTP: Execute action
Note over AES: Run in sandbox
AES-->>AS: Observation result
AS->>User: WebSocket: Event update
Note over AS: Update state, next action
end
Note over User,AES: Phase 6: Task Complete
AS->>User: WebSocket: AgentStateChanged (FINISHED)
```
### Key Points
1. **Initial Setup via App Server**: The App Server handles authentication and coordinates with the Sandbox Service
2. **Runtime API Provisioning**: The Sandbox Service calls the Runtime API, which checks for warm runtimes before creating new containers
3. **Warm Pool Optimization**: Pre-warmed runtimes reduce startup latency significantly
4. **Direct WebSocket to Sandbox**: Once created, the user's browser connects **directly** to the Agent Server inside the sandbox
5. **App Server Not in Hot Path**: After connection, all real-time communication bypasses the App Server entirely
6. **Agent Server Orchestrates**: The Agent Server manages the AI loop, calling the Action Execution Server for actual command execution

View File

@@ -0,0 +1,85 @@
# Observability
OpenHands provides structured logging and metrics collection for monitoring and debugging.
> **SDK Documentation**: For detailed guidance on observability and metrics in agent development, see:
> - [SDK Observability Guide](https://docs.openhands.dev/sdk/guides/observability)
> - [SDK Metrics Guide](https://docs.openhands.dev/sdk/guides/metrics)
```mermaid
flowchart LR
subgraph Sources["Sources"]
Agent["Agent Server"]
App["App Server"]
Frontend["Frontend"]
end
subgraph Collection["Collection"]
JSONLog["JSON Logs<br/>(stdout)"]
Metrics["Metrics<br/>(Internal)"]
end
subgraph External["External (Optional)"]
LogAgg["Log Aggregator"]
Analytics["Analytics Service"]
end
Agent --> JSONLog
App --> JSONLog
App --> Metrics
JSONLog --> LogAgg
Frontend --> Analytics
```
### Structured Logging
OpenHands uses Python's standard logging library with structured JSON output support.
| Component | Format | Destination | Purpose |
|-----------|--------|-------------|---------|
| **Application Logs** | JSON (when `LOG_JSON=1`) | stdout | Debugging, error tracking |
| **Access Logs** | JSON (Uvicorn) | stdout | Request tracing |
| **LLM Debug Logs** | Plain text | File (optional) | LLM call debugging |
### JSON Log Format
When `LOG_JSON=1` is set, logs are emitted as single-line JSON for ingestion by log aggregators:
```json
{
"message": "Conversation started",
"severity": "INFO",
"conversation_id": "abc-123",
"user_id": "user-456",
"timestamp": "2024-01-15T10:30:00Z"
}
```
Additional context can be added using Python's logger `extra=` parameter (see [Python logging docs](https://docs.python.org/3/library/logging.html)).
### Metrics
| Metric | Tracked By | Storage | Purpose |
|--------|------------|---------|---------|
| **LLM Cost** | `Metrics` class | Conversation stats file | Billing, budget limits |
| **Token Usage** | `Metrics` class | Conversation stats file | Usage analytics |
| **Response Latency** | `Metrics` class | Conversation stats file | Performance monitoring |
### Conversation Stats Persistence
Per-conversation metrics are persisted for analytics:
```python
# Location: openhands/server/services/conversation_stats.py
ConversationStats:
- service_to_metrics: Dict[str, Metrics]
- accumulated_cost: float
- token_usage: TokenUsage
# Stored at: {file_store}/conversation_stats/{conversation_id}.pkl
```
### Integration with External Services
Structured JSON logging allows integration with any log aggregation service (e.g., ELK Stack, Loki, Splunk). Configure your log collector to ingest from container stdout/stderr.

View File

@@ -0,0 +1,88 @@
# System Architecture Overview
OpenHands supports multiple deployment configurations. This document describes the core components and how they interact.
## Local/Docker Deployment
The simplest deployment runs everything locally or in Docker containers:
```mermaid
flowchart TB
subgraph Server["OpenHands Server"]
API["REST API<br/>(FastAPI)"]
ConvMgr["Conversation<br/>Manager"]
Runtime["Runtime<br/>Manager"]
end
subgraph Sandbox["Sandbox (Docker Container)"]
AES["Action Execution<br/>Server"]
Browser["Browser<br/>Environment"]
FS["File System"]
end
User["User"] -->|"HTTP/WebSocket"| API
API --> ConvMgr
ConvMgr --> Runtime
Runtime -->|"Provision"| Sandbox
Server -->|"Execute actions"| AES
AES --> Browser
AES --> FS
```
### Core Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Server** | REST API, conversation management, runtime orchestration | `openhands/server/` |
| **Runtime** | Abstract interface for sandbox execution | `openhands/runtime/` |
| **Action Execution Server** | Execute bash, file ops, browser actions | Inside sandbox |
| **EventStream** | Central event bus for all communication | `openhands/events/` |
## Scalable Deployment
For production deployments, OpenHands can be configured with a separate Runtime API service:
```mermaid
flowchart TB
subgraph AppServer["App Server"]
API["REST API"]
ConvMgr["Conversation<br/>Manager"]
end
subgraph RuntimeAPI["Runtime API (Optional)"]
RuntimeMgr["Runtime<br/>Manager"]
WarmPool["Warm Pool"]
end
subgraph Sandbox["Sandbox"]
AS["Agent Server"]
AES["Action Execution<br/>Server"]
end
User["User"] -->|"HTTP"| API
API --> ConvMgr
ConvMgr -->|"Provision"| RuntimeMgr
RuntimeMgr --> WarmPool
RuntimeMgr --> Sandbox
User -.->|"WebSocket"| AS
AS -->|"HTTP"| AES
```
This configuration enables:
- **Warm pool**: Pre-provisioned runtimes for faster startup
- **Direct WebSocket**: Users connect directly to their sandbox, bypassing the App Server
- **Horizontal scaling**: App Server and Runtime API can scale independently
### Runtime Options
OpenHands supports multiple runtime implementations:
| Runtime | Use Case |
|---------|----------|
| **DockerRuntime** | Local development, single-machine deployments |
| **RemoteRuntime** | Connect to externally managed sandboxes |
| **ModalRuntime** | Serverless execution via Modal |
See the [Runtime documentation](https://docs.openhands.dev/usage/architecture/runtime) for details.

View File

@@ -89,9 +89,7 @@ def _make_git_show_cmd(ref: str, repo_relative_path: str) -> str:
def get_git_diff(relative_file_path: str) -> dict[str, str]:
path = Path(os.getcwd(), relative_file_path).resolve()
file_exists = path.exists()
# Only check file size if the file exists (deleted files won't exist)
if file_exists and os.path.getsize(path) > MAX_FILE_SIZE_FOR_GIT_DIFF:
if os.path.getsize(path) > MAX_FILE_SIZE_FOR_GIT_DIFF:
raise ValueError('file_to_large')
closest_git_repo = get_closest_git_repo(path)
if not closest_git_repo:

8
poetry.lock generated
View File

@@ -606,14 +606,14 @@ files = [
[[package]]
name = "authlib"
version = "1.6.7"
version = "1.6.9"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
]
[package.dependencies]
@@ -14833,4 +14833,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "b8a9c6245f0c3cabfeaffe6eb7c1fae76391a15533c18bce1fe168e070a66d63"
content-hash = "1a8151b36fb64667d1a2e83f38060841de15bd0284f18e8f58c6ee95095e933e"

View File

@@ -25,7 +25,7 @@ dependencies = [
"anthropic[vertex]",
"anyio==4.9",
"asyncpg>=0.30",
"authlib>=1.6.7",
"authlib>=1.6.9",
"bashlex>=0.18",
"boto3",
"browsergym-core==0.13.3",
@@ -163,7 +163,7 @@ include = [
[tool.poetry.dependencies]
python = "^3.12,<3.14"
authlib = ">=1.6.7" # Pinned to fix CVE-2026-28802
authlib = ">=1.6.9" # CVE-2026-27962 (fixed in 1.6.9)
orjson = ">=3.11.6" # Pinned to fix CVE-2025-67221
litellm = ">=1.74.3, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
openai = "2.8.0" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)

View File

@@ -0,0 +1,787 @@
"""Unit + integration tests for the sandbox settings endpoints and /users/me expose_secrets.
Tests:
- GET /api/v1/users/me?expose_secrets=true
- GET /api/v1/sandboxes/{sandbox_id}/settings/secrets
- GET /api/v1/sandboxes/{sandbox_id}/settings/secrets/{secret_name}
- Shared session_auth.validate_session_key()
- Integration tests exercising the real auth validation stack via HTTP
"""
import contextlib
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import SecretStr
from openhands.app_server.sandbox.sandbox_models import (
SandboxInfo,
SandboxStatus,
SecretNamesResponse,
)
from openhands.app_server.sandbox.sandbox_router import (
get_secret_value,
list_secret_names,
)
from openhands.app_server.sandbox.session_auth import validate_session_key
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.app_server.user.user_router import (
_validate_session_key_ownership,
get_current_user,
)
from openhands.integrations.provider import ProviderHandler, ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import StaticSecret
SANDBOX_ID = 'sb-test-123'
USER_ID = 'test-user-id'
def _make_sandbox_info(
sandbox_id: str = SANDBOX_ID,
user_id: str | None = USER_ID,
) -> SandboxInfo:
return SandboxInfo(
id=sandbox_id,
created_by_user_id=user_id,
sandbox_spec_id='test-spec',
status=SandboxStatus.RUNNING,
session_api_key='session-key',
)
def _patch_sandbox_service(return_sandbox: SandboxInfo | None):
"""Patch ``get_sandbox_service`` in ``session_auth`` to return a mock service."""
mock_sandbox_service = AsyncMock()
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
return_value=return_sandbox
)
ctx = patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
)
return ctx, mock_sandbox_service
def _create_sandbox_service_context_manager(sandbox_service):
"""Create an async context manager that yields the given sandbox service."""
@contextlib.asynccontextmanager
async def _context_manager(state, request=None):
yield sandbox_service
return _context_manager
# ---------------------------------------------------------------------------
# validate_session_key (shared utility)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestValidateSessionKey:
"""Tests for the shared session_auth.validate_session_key utility."""
async def test_rejects_missing_key(self):
"""Missing session key raises 401."""
with pytest.raises(HTTPException) as exc_info:
await validate_session_key(None)
assert exc_info.value.status_code == 401
assert 'X-Session-API-Key' in exc_info.value.detail
async def test_rejects_empty_string_key(self):
"""Empty string session key raises 401."""
with pytest.raises(HTTPException) as exc_info:
await validate_session_key('')
assert exc_info.value.status_code == 401
async def test_rejects_invalid_key(self):
"""Session key that maps to no sandbox raises 401."""
ctx, mock_svc = _patch_sandbox_service(None)
with ctx as mock_get:
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
with pytest.raises(HTTPException) as exc_info:
await validate_session_key('bogus-key')
assert exc_info.value.status_code == 401
assert 'Invalid session API key' in exc_info.value.detail
async def test_accepts_valid_key(self):
"""Valid session key returns sandbox info."""
sandbox = _make_sandbox_info()
ctx, mock_svc = _patch_sandbox_service(sandbox)
with ctx as mock_get:
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
result = await validate_session_key('valid-key')
assert result.id == SANDBOX_ID
async def test_rejects_sandbox_without_user_in_saas_mode(self):
"""In SAAS mode, sandbox without created_by_user_id raises 401."""
sandbox = _make_sandbox_info(user_id=None)
ctx, mock_svc = _patch_sandbox_service(sandbox)
with (
ctx as mock_get,
patch(
'openhands.app_server.sandbox.session_auth.get_global_config'
) as mock_cfg,
):
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
from openhands.server.types import AppMode
mock_cfg.return_value.app_mode = AppMode.SAAS
with pytest.raises(HTTPException) as exc_info:
await validate_session_key('valid-key')
assert exc_info.value.status_code == 401
assert 'no user' in exc_info.value.detail
# ---------------------------------------------------------------------------
# GET /users/me?expose_secrets=true
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGetCurrentUserExposeSecrets:
"""Test suite for GET /users/me?expose_secrets=true."""
async def test_expose_secrets_returns_raw_api_key(self):
"""With valid session key, expose_secrets=true returns unmasked llm_api_key."""
user_info = UserInfo(
id=USER_ID,
llm_model='anthropic/claude-sonnet-4-20250514',
llm_api_key=SecretStr('sk-test-key-123'),
llm_base_url='https://litellm.example.com',
)
mock_context = AsyncMock()
mock_context.get_user_info = AsyncMock(return_value=user_info)
mock_context.get_user_id = AsyncMock(return_value=USER_ID)
with patch(
'openhands.app_server.user.user_router._validate_session_key_ownership'
) as mock_validate:
mock_validate.return_value = None
result = await get_current_user(
user_context=mock_context,
expose_secrets=True,
x_session_api_key='valid-key',
)
# JSONResponse — parse the body
import json
body = json.loads(result.body)
assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514'
assert body['llm_api_key'] == 'sk-test-key-123'
assert body['llm_base_url'] == 'https://litellm.example.com'
async def test_expose_secrets_rejects_missing_session_key(self):
"""expose_secrets=true without X-Session-API-Key is rejected."""
mock_context = AsyncMock()
with pytest.raises(HTTPException) as exc_info:
await _validate_session_key_ownership(mock_context, session_api_key=None)
assert exc_info.value.status_code == 401
assert 'X-Session-API-Key' in exc_info.value.detail
async def test_expose_secrets_rejects_wrong_user(self):
"""expose_secrets=true with session key from different user is rejected."""
mock_context = AsyncMock()
mock_context.get_user_id = AsyncMock(return_value='user-A')
other_user_sandbox = _make_sandbox_info(user_id='user-B')
ctx, mock_svc = _patch_sandbox_service(other_user_sandbox)
with ctx as mock_get, pytest.raises(HTTPException) as exc_info:
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
await _validate_session_key_ownership(
mock_context, session_api_key='stolen-key'
)
assert exc_info.value.status_code == 403
async def test_expose_secrets_rejects_unknown_caller(self):
"""If caller_id cannot be determined, reject with 401."""
mock_context = AsyncMock()
mock_context.get_user_id = AsyncMock(return_value=None)
sandbox = _make_sandbox_info(user_id='user-B')
ctx, mock_svc = _patch_sandbox_service(sandbox)
with ctx as mock_get, pytest.raises(HTTPException) as exc_info:
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
await _validate_session_key_ownership(
mock_context, session_api_key='some-key'
)
assert exc_info.value.status_code == 401
assert 'Cannot determine authenticated user' in exc_info.value.detail
async def test_default_masks_api_key(self):
"""Without expose_secrets, llm_api_key is masked (no session key needed)."""
user_info = UserInfo(
id=USER_ID,
llm_api_key=SecretStr('sk-test-key-123'),
)
mock_context = AsyncMock()
mock_context.get_user_info = AsyncMock(return_value=user_info)
result = await get_current_user(
user_context=mock_context, expose_secrets=False, x_session_api_key=None
)
# Returns UserInfo directly (FastAPI will serialize with masking)
assert isinstance(result, UserInfo)
assert result.llm_api_key is not None
# The raw value is still in the object, but serialization masks it
dumped = result.model_dump(mode='json')
assert dumped['llm_api_key'] == '**********'
# ---------------------------------------------------------------------------
# GET /sandboxes/{sandbox_id}/settings/secrets
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestListSecretNames:
"""Test suite for GET /sandboxes/{sandbox_id}/settings/secrets."""
async def test_returns_secret_names_without_values(self):
"""Response contains names and descriptions, NOT raw values."""
secrets = {
'GITHUB_TOKEN': StaticSecret(
value=SecretStr('ghp_test123'),
description='GitHub personal access token',
),
'MY_API_KEY': StaticSecret(
value=SecretStr('my-api-key-value'),
description='Custom API key',
),
}
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value=secrets)
ctx.get_provider_tokens = AsyncMock(return_value={})
mock_ctx.return_value = ctx
result = await list_secret_names(sandbox_info=sandbox_info)
assert isinstance(result, SecretNamesResponse)
assert len(result.secrets) == 2
names = {s.name for s in result.secrets}
assert 'GITHUB_TOKEN' in names
assert 'MY_API_KEY' in names
gh = next(s for s in result.secrets if s.name == 'GITHUB_TOKEN')
assert gh.description == 'GitHub personal access token'
# Verify no 'value' field is exposed
assert not hasattr(gh, 'value')
async def test_returns_empty_when_no_secrets(self):
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value={})
ctx.get_provider_tokens = AsyncMock(return_value={})
mock_ctx.return_value = ctx
result = await list_secret_names(sandbox_info=sandbox_info)
assert len(result.secrets) == 0
# ---------------------------------------------------------------------------
# GET /sandboxes/{sandbox_id}/settings/secrets/{name}
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGetSecretValue:
"""Test suite for GET /sandboxes/{sandbox_id}/settings/secrets/{name}."""
async def test_returns_raw_secret_value(self):
"""Raw secret value returned as plain text."""
secrets = {
'GITHUB_TOKEN': StaticSecret(
value=SecretStr('ghp_actual_secret'),
description='GitHub token',
),
}
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value=secrets)
ctx.get_provider_tokens = AsyncMock(return_value={})
mock_ctx.return_value = ctx
response = await get_secret_value(
secret_name='GITHUB_TOKEN',
sandbox_info=sandbox_info,
)
assert response.body == b'ghp_actual_secret'
assert response.media_type == 'text/plain'
async def test_returns_404_for_unknown_secret(self):
"""404 when requested secret doesn't exist in custom secrets or provider tokens."""
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value={})
ctx.get_provider_tokens = AsyncMock(return_value={})
mock_ctx.return_value = ctx
with pytest.raises(HTTPException) as exc_info:
await get_secret_value(
secret_name='NONEXISTENT',
sandbox_info=sandbox_info,
)
assert exc_info.value.status_code == 404
async def test_returns_404_for_none_value_secret(self):
"""404 when secret exists but has None value."""
secrets = {
'EMPTY_SECRET': StaticSecret(value=None),
}
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value=secrets)
ctx.get_provider_tokens = AsyncMock(return_value={})
mock_ctx.return_value = ctx
with pytest.raises(HTTPException) as exc_info:
await get_secret_value(
secret_name='EMPTY_SECRET',
sandbox_info=sandbox_info,
)
assert exc_info.value.status_code == 404
# ===========================================================================
# Integration tests — real HTTP requests through real auth validation logic.
#
# Only the data layer (sandbox service, user context) is mocked.
# The session key validation, ownership checks, and FastAPI routing are REAL.
# ===========================================================================
def _build_integration_test_app(
mock_user_context: AsyncMock | None = None,
) -> FastAPI:
"""Build a minimal FastAPI app with the real user and sandbox routers.
The ``depends_user_context`` dependency is overridden with a mock, but the
session key validation logic in ``validate_session_key`` and
``_validate_session_key_ownership`` runs unmodified.
Router-level dependencies (e.g. ``check_session_api_key`` from ``SESSION_API_KEY``
env var) are overridden to no-ops so we can exercise the endpoint-level auth logic
in isolation.
"""
from openhands.app_server.sandbox.sandbox_router import (
router as sandbox_router,
)
from openhands.app_server.user.user_router import router as user_router
from openhands.server.dependencies import check_session_api_key
app = FastAPI()
# Disable router-level auth (SESSION_API_KEY check) — we're testing the
# endpoint-level session key validation, not the router middleware.
app.dependency_overrides[check_session_api_key] = lambda: None
if mock_user_context is not None:
from openhands.app_server.user.user_router import user_dependency
app.dependency_overrides[user_dependency.dependency] = lambda: mock_user_context
app.include_router(user_router, prefix='/api/v1')
app.include_router(sandbox_router, prefix='/api/v1')
return app
class TestExposeSecretsIntegration:
"""Integration tests for /users/me?expose_secrets=true via real HTTP.
These tests exercise the full auth validation stack:
- validate_session_key (real)
- _validate_session_key_ownership (real)
- ownership check (real)
Only the data layer (sandbox service lookup, user context) is mocked.
"""
def test_expose_secrets_without_session_key_returns_401(self):
"""Bearer token alone cannot expose secrets (no X-Session-API-Key)."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123'))
)
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
app = _build_integration_test_app(mock_user_ctx)
client = TestClient(app, raise_server_exceptions=False)
response = client.get('/api/v1/users/me', params={'expose_secrets': 'true'})
assert response.status_code == 401
assert 'X-Session-API-Key' in response.json()['detail']
def test_expose_secrets_with_invalid_session_key_returns_401(self):
"""Invalid session key (no matching sandbox) is rejected."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123'))
)
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
mock_sandbox_svc = AsyncMock()
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(return_value=None)
app = _build_integration_test_app(mock_user_ctx)
client = TestClient(app, raise_server_exceptions=False)
with patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
_create_sandbox_service_context_manager(mock_sandbox_svc),
):
response = client.get(
'/api/v1/users/me',
params={'expose_secrets': 'true'},
headers={'X-Session-API-Key': 'bogus-key'},
)
assert response.status_code == 401
assert 'Invalid session API key' in response.json()['detail']
def test_expose_secrets_with_wrong_user_returns_403(self):
"""Session key from a different user's sandbox is rejected."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(id='user-A', llm_api_key=SecretStr('sk-secret-123'))
)
mock_user_ctx.get_user_id = AsyncMock(return_value='user-A')
# Sandbox owned by user-B
sandbox_b = _make_sandbox_info(user_id='user-B')
mock_sandbox_svc = AsyncMock()
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
return_value=sandbox_b
)
app = _build_integration_test_app(mock_user_ctx)
client = TestClient(app, raise_server_exceptions=False)
with patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
_create_sandbox_service_context_manager(mock_sandbox_svc),
):
response = client.get(
'/api/v1/users/me',
params={'expose_secrets': 'true'},
headers={'X-Session-API-Key': 'stolen-key'},
)
assert response.status_code == 403
assert 'does not belong' in response.json()['detail']
def test_expose_secrets_valid_dual_auth_returns_200_unmasked(self):
"""Valid Bearer + valid session key owned by same user → 200 with secrets."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(
id=USER_ID,
llm_model='anthropic/claude-sonnet-4-20250514',
llm_api_key=SecretStr('sk-real-secret'),
llm_base_url='https://litellm.example.com',
)
)
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
sandbox = _make_sandbox_info(user_id=USER_ID)
mock_sandbox_svc = AsyncMock()
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
return_value=sandbox
)
app = _build_integration_test_app(mock_user_ctx)
client = TestClient(app, raise_server_exceptions=False)
with patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
_create_sandbox_service_context_manager(mock_sandbox_svc),
):
response = client.get(
'/api/v1/users/me',
params={'expose_secrets': 'true'},
headers={'X-Session-API-Key': 'valid-key'},
)
assert response.status_code == 200
body = response.json()
assert body['llm_api_key'] == 'sk-real-secret'
assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514'
assert body['llm_base_url'] == 'https://litellm.example.com'
def test_default_masks_secrets_via_http(self):
"""Without expose_secrets, secrets are masked even via real HTTP."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(
id=USER_ID, llm_api_key=SecretStr('sk-should-be-masked')
)
)
app = _build_integration_test_app(mock_user_ctx)
client = TestClient(app, raise_server_exceptions=False)
response = client.get('/api/v1/users/me')
assert response.status_code == 200
body = response.json()
assert body['llm_api_key'] == '**********'
class TestSandboxSecretsIntegration:
"""Integration tests for sandbox-scoped secrets endpoints via real HTTP.
The session key validation in ``_valid_sandbox_from_session_key`` runs
unmodified — only the sandbox service (database) is mocked.
"""
def test_secrets_list_without_session_key_returns_401(self):
"""Missing X-Session-API-Key on secrets endpoint is rejected."""
app = _build_integration_test_app()
client = TestClient(app, raise_server_exceptions=False)
response = client.get(f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets')
assert response.status_code == 401
assert 'X-Session-API-Key' in response.json()['detail']
def test_secrets_list_with_invalid_session_key_returns_401(self):
"""Invalid session key on secrets endpoint is rejected."""
app = _build_integration_test_app()
client = TestClient(app, raise_server_exceptions=False)
mock_sandbox_svc = AsyncMock()
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(return_value=None)
with patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
_create_sandbox_service_context_manager(mock_sandbox_svc),
):
response = client.get(
f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets',
headers={'X-Session-API-Key': 'bogus'},
)
assert response.status_code == 401
assert 'Invalid session API key' in response.json()['detail']
def test_secrets_list_with_mismatched_sandbox_id_returns_403(self):
"""Session key maps to a different sandbox than the URL path → 403."""
app = _build_integration_test_app()
client = TestClient(app, raise_server_exceptions=False)
# Session key maps to sandbox "other-sandbox", but URL says SANDBOX_ID
other_sandbox = _make_sandbox_info(sandbox_id='other-sandbox')
mock_sandbox_svc = AsyncMock()
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
return_value=other_sandbox
)
with patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
_create_sandbox_service_context_manager(mock_sandbox_svc),
):
response = client.get(
f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets',
headers={'X-Session-API-Key': 'valid-key'},
)
assert response.status_code == 403
assert 'does not match' in response.json()['detail']
def test_sandbox_without_user_returns_401_for_secret_value(self):
"""Sandbox with no owning user → 401 when fetching a secret value."""
app = _build_integration_test_app()
client = TestClient(app, raise_server_exceptions=False)
# Sandbox exists but has no owning user
sandbox_no_user = _make_sandbox_info(user_id=None)
mock_sandbox_svc = AsyncMock()
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
return_value=sandbox_no_user
)
with patch(
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
_create_sandbox_service_context_manager(mock_sandbox_svc),
):
response = client.get(
f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets/MY_SECRET',
headers={'X-Session-API-Key': 'valid-key'},
)
# _get_user_context raises 401 because created_by_user_id is None
assert response.status_code == 401
assert 'no associated user' in response.json()['detail']
# ---------------------------------------------------------------------------
# Provider tokens in sandbox secrets endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestProviderTokensInEndpoints:
"""Verify that sandbox secrets endpoints include provider tokens resolved lazily."""
async def test_get_provider_tokens_as_env_vars(self):
"""get_provider_tokens(as_env_vars=True) returns fresh values keyed by env name."""
mock_user_auth = AsyncMock()
mock_user_auth.get_provider_tokens = AsyncMock(
return_value={
ProviderType.GITHUB: ProviderToken(token=SecretStr('ghp_test123')),
ProviderType.GITLAB: ProviderToken(token=SecretStr('glpat-test456')),
}
)
ctx = AuthUserContext(user_auth=mock_user_auth)
result = await ctx.get_provider_tokens(as_env_vars=True)
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
gl_key = ProviderHandler.get_provider_env_key(ProviderType.GITLAB)
assert result[gh_key] == 'ghp_test123'
assert result[gl_key] == 'glpat-test456'
async def test_empty_provider_tokens_excluded(self):
"""Provider tokens with empty token values are excluded."""
mock_user_auth = AsyncMock()
mock_user_auth.get_provider_tokens = AsyncMock(
return_value={
ProviderType.GITHUB: ProviderToken(token=SecretStr('')),
}
)
ctx = AuthUserContext(user_auth=mock_user_auth)
result = await ctx.get_provider_tokens(as_env_vars=True)
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
assert gh_key not in result
async def test_none_provider_tokens_returns_empty(self):
"""get_provider_tokens(as_env_vars=True) with None tokens yields empty dict."""
mock_user_auth = AsyncMock()
mock_user_auth.get_provider_tokens = AsyncMock(return_value=None)
ctx = AuthUserContext(user_auth=mock_user_auth)
result = await ctx.get_provider_tokens(as_env_vars=True)
assert result == {}
async def test_list_secret_names_includes_provider_tokens(self):
"""list_secret_names returns both custom secrets and provider token names."""
custom_secrets = {
'MY_KEY': StaticSecret(
value=SecretStr('my-value'), description='custom key'
),
}
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
provider_env_vars = {gh_key: 'ghp_test123'}
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value=custom_secrets)
ctx.get_provider_tokens = AsyncMock(return_value=provider_env_vars)
mock_ctx.return_value = ctx
result = await list_secret_names(sandbox_info=sandbox_info)
names = {s.name for s in result.secrets}
assert 'MY_KEY' in names
assert gh_key in names
assert len(result.secrets) == 2
async def test_get_secret_value_resolves_provider_token(self):
"""get_secret_value falls back to provider tokens when not in custom secrets."""
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(return_value={})
ctx.get_provider_tokens = AsyncMock(
return_value={gh_key: 'ghp_fresh_token'}
)
mock_ctx.return_value = ctx
response = await get_secret_value(
secret_name=gh_key, sandbox_info=sandbox_info
)
assert response.body == b'ghp_fresh_token'
assert response.media_type == 'text/plain'
async def test_custom_secret_takes_priority_over_provider_token(self):
"""If a custom secret has the same name, it takes priority."""
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
sandbox_info = _make_sandbox_info()
with patch(
'openhands.app_server.sandbox.sandbox_router._get_user_context'
) as mock_ctx:
ctx = AsyncMock()
ctx.get_secrets = AsyncMock(
return_value={
gh_key: StaticSecret(
value=SecretStr('custom-override'),
description='user override',
)
}
)
# Provider token should NOT be called since custom secret matches
ctx.get_provider_tokens = AsyncMock(return_value={gh_key: 'provider-value'})
mock_ctx.return_value = ctx
response = await get_secret_value(
secret_name=gh_key, sandbox_info=sandbox_info
)
assert response.body == b'custom-override'

View File

@@ -254,33 +254,6 @@ class TestGitHandler(unittest.TestCase):
}
assert diff == expected_diff
def test_get_git_diff_committed_delete(self):
"""Test that get_git_diff handles committed deleted files."""
diff = self.git_handler.get_git_diff('committed_delete.txt')
expected_diff = {
'original': 'committed_delete.txt\nLine 1\nLine 2\nLine 3',
'modified': '',
}
assert diff == expected_diff
def test_get_git_diff_staged_delete(self):
"""Test that get_git_diff handles staged deleted files."""
diff = self.git_handler.get_git_diff('staged_delete.txt')
expected_diff = {
'original': 'staged_delete.txt\nLine 1\nLine 2\nLine 3',
'modified': '',
}
assert diff == expected_diff
def test_get_git_diff_unstaged_delete(self):
"""Test that get_git_diff handles unstaged deleted files."""
diff = self.git_handler.get_git_diff('unstaged_delete.txt')
expected_diff = {
'original': 'unstaged_delete.txt\nLine 1\nLine 2\nLine 3',
'modified': '',
}
assert diff == expected_diff
def test_get_git_changes_fallback(self):
"""Test that get_git_changes falls back to creating a script file when needed."""
# Break the git changes command
@@ -327,10 +300,9 @@ class TestGitHandler(unittest.TestCase):
# A payload that would create the sentinel file if injection were possible
malicious_path = f'"; touch {sentinel}; echo "'
# get_git_diff should safely handle the malicious path without executing it
# For non-existent files (like malicious paths), it returns empty content
# The important check is that no shell injection occurs
self.git_handler.get_git_diff(malicious_path)
# get_git_diff should raise (no such file) rather than executing the payload
with self.assertRaises(ValueError):
self.git_handler.get_git_diff(malicious_path)
assert not os.path.exists(sentinel), (
'Shell injection succeeded: sentinel file was created'
@@ -377,15 +349,9 @@ class TestGitShowCmdBuilder:
def test_get_git_diff_file_too_large():
"""Raises ValueError('file_to_large') when the file exceeds the size limit."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create an actual file so that path.exists() returns True
file_path = os.path.join(tmp_dir, 'large_file.txt')
Path(file_path).write_text('content')
with patch(
'os.path.getsize', return_value=git_diff.MAX_FILE_SIZE_FOR_GIT_DIFF + 1
):
with pytest.raises(ValueError, match='file_to_large'):
git_diff.get_git_diff(file_path)
with patch('os.path.getsize', return_value=git_diff.MAX_FILE_SIZE_FOR_GIT_DIFF + 1):
with pytest.raises(ValueError, match='file_to_large'):
git_diff.get_git_diff('/nonexistent/path.txt')
def test_get_git_diff_no_repository():

10
uv.lock generated
View File

@@ -360,14 +360,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.7"
version = "1.6.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
]
[[package]]
@@ -3792,7 +3792,7 @@ requires-dist = [
{ name = "anthropic", extras = ["vertex"] },
{ name = "anyio", specifier = "==4.9" },
{ name = "asyncpg", specifier = ">=0.30" },
{ name = "authlib", specifier = ">=1.6.7" },
{ name = "authlib", specifier = ">=1.6.9" },
{ name = "bashlex", specifier = ">=0.18" },
{ name = "boto3" },
{ name = "browsergym-core", specifier = "==0.13.3" },
@@ -3844,7 +3844,7 @@ requires-dist = [
{ name = "psutil" },
{ name = "pybase62", specifier = ">=1" },
{ name = "pygithub", specifier = ">=2.5" },
{ name = "pyjwt", specifier = ">=2.12.0" },
{ name = "pyjwt", specifier = ">=2.12" },
{ name = "pylatexenc" },
{ name = "pypdf", specifier = ">=6.7.2" },
{ name = "python-docx" },