mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
8 Commits
fix/git-di
...
fix-path-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269e27e734 | ||
|
|
79cfffce60 | ||
|
|
b68c75252d | ||
|
|
d58e12ad74 | ||
|
|
bd837039dd | ||
|
|
8a7779068a | ||
|
|
38099934b6 | ||
|
|
75c823c486 |
29
AGENTS.md
29
AGENTS.md
@@ -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`
|
||||
|
||||
165
CONTRIBUTING.md
165
CONTRIBUTING.md
@@ -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
|
||||
|
||||
382
Development.md
382
Development.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
13
enterprise/doc/architecture/README.md
Normal file
13
enterprise/doc/architecture/README.md
Normal 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)
|
||||
58
enterprise/doc/architecture/authentication.md
Normal file
58
enterprise/doc/architecture/authentication.md
Normal 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)
|
||||
88
enterprise/doc/architecture/external-integrations.md
Normal file
88
enterprise/doc/architecture/external-integrations.md
Normal 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 │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
@@ -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]:
|
||||
|
||||
6
enterprise/poetry.lock
generated
6
enterprise/poetry.lock
generated
@@ -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]
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
66
openhands/app_server/sandbox/session_auth.py
Normal file
66
openhands/app_server/sandbox/session_auth.py
Normal 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
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
10
openhands/architecture/README.md
Normal file
10
openhands/architecture/README.md
Normal 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
|
||||
92
openhands/architecture/agent-execution.md
Normal file
92
openhands/architecture/agent-execution.md
Normal 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.
|
||||
68
openhands/architecture/conversation-startup.md
Normal file
68
openhands/architecture/conversation-startup.md
Normal 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
|
||||
85
openhands/architecture/observability.md
Normal file
85
openhands/architecture/observability.md
Normal 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.
|
||||
88
openhands/architecture/system-architecture.md
Normal file
88
openhands/architecture/system-architecture.md
Normal 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.
|
||||
@@ -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
8
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
787
tests/unit/app_server/test_sandbox_secrets_router.py
Normal file
787
tests/unit/app_server/test_sandbox_secrets_router.py
Normal 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'
|
||||
@@ -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
10
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user