mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
76 Commits
fix-cli-co
...
auth-syste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8316ec9eb3 | ||
|
|
059e5b7af6 | ||
|
|
d858882c3d | ||
|
|
49d37119a9 | ||
|
|
cfd416c29f | ||
|
|
c052dd7da5 | ||
|
|
3f77b8229a | ||
|
|
8d13c9f328 | ||
|
|
f46b112f17 | ||
|
|
44dc7f9e9b | ||
|
|
00eaa7a6e1 | ||
|
|
9f1d6963b8 | ||
|
|
f61fa93596 | ||
|
|
3e87c08631 | ||
|
|
21f3ef540f | ||
|
|
61a93d010c | ||
|
|
9d6afa09b6 | ||
|
|
c648b6f74f | ||
|
|
c0fa41da65 | ||
|
|
6eb32e9ae4 | ||
|
|
6a544d4274 | ||
|
|
4aada82b75 | ||
|
|
ab2da611f5 | ||
|
|
e47bcf31e4 | ||
|
|
83b9262379 | ||
|
|
edc95141f7 | ||
|
|
5b35203253 | ||
|
|
7e3eabe777 | ||
|
|
23713bfe8c | ||
|
|
81829289ab | ||
|
|
9709431874 | ||
|
|
0e9906f41e | ||
|
|
9ac9a47207 | ||
|
|
75653e805a | ||
|
|
9630b536cd | ||
|
|
6f5c8186b8 | ||
|
|
36e0d8d3da | ||
|
|
e68abf8d75 | ||
|
|
93ef1b0cda | ||
|
|
77b5c6b161 | ||
|
|
57aa7d5c12 | ||
|
|
50391ecdf3 | ||
|
|
672650d3d9 | ||
|
|
9afedea170 | ||
|
|
c0bb84dfa2 | ||
|
|
18b5139237 | ||
|
|
4849369ede | ||
|
|
b082ccc0fb | ||
|
|
b0007076c0 | ||
|
|
4a4f213f57 | ||
|
|
f9099fe6db | ||
|
|
8f46a0a7a3 | ||
|
|
55d204ae1b | ||
|
|
4d7cd228da | ||
|
|
a3f92df4b3 | ||
|
|
e41f8f5215 | ||
|
|
6448f5a681 | ||
|
|
5fcc648d5f | ||
|
|
c9d96038c1 | ||
|
|
408af4e012 | ||
|
|
d9ac2faff6 | ||
|
|
64383a66e2 | ||
|
|
7fbcb29499 | ||
|
|
e7aae1495c | ||
|
|
d33f27d141 | ||
|
|
d08851859b | ||
|
|
7f4d311294 | ||
|
|
049f058ed1 | ||
|
|
bb6cf5a816 | ||
|
|
d9bc5824a0 | ||
|
|
fd5b5075d6 | ||
|
|
f5cd7b256d | ||
|
|
df86fd275d | ||
|
|
d22a2e39e7 | ||
|
|
ca424ec15d | ||
|
|
4507a25b85 |
1
.github/workflows/e2e-tests.yml
vendored
1
.github/workflows/e2e-tests.yml
vendored
@@ -187,6 +187,7 @@ jobs:
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
|
||||
4
.github/workflows/ghcr-build.yml
vendored
4
.github/workflows/ghcr-build.yml
vendored
@@ -225,7 +225,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
days-before-stale: 40
|
||||
exempt-issue-labels: 'roadmap'
|
||||
exempt-issue-labels: roadmap,backlog
|
||||
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
|
||||
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
|
||||
days-before-close: 10
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -257,3 +257,5 @@ containers/runtime/code
|
||||
|
||||
# test results
|
||||
test-results
|
||||
.sessions
|
||||
.eval_sessions
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
9
LICENSE
9
LICENSE
@@ -1,7 +1,12 @@
|
||||
The MIT License (MIT)
|
||||
Portions of this software are licensed as follows:
|
||||
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
|
||||
|
||||
=====================
|
||||
|
||||
Copyright © 2023
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -130,7 +130,6 @@ If you want to modify the OpenHands source code, check out [Development.md](http
|
||||
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help.
|
||||
|
||||
## 📖 Documentation
|
||||
<a href="https://deepwiki.com/All-Hands-AI/OpenHands"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" title="Autogenerated Documentation by DeepWiki"></a>
|
||||
|
||||
To learn more about the project, and for tips on using OpenHands,
|
||||
check out our [documentation](https://docs.all-hands.dev/usage/getting-started).
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -363,10 +363,11 @@ classpath = "my_package.my_module.MyCustomAgent"
|
||||
#confirmation_mode = false
|
||||
|
||||
# The security analyzer to use (For Headless / CLI only - In Web this is overridden by Session Init)
|
||||
#security_analyzer = ""
|
||||
# Available options: 'llm' (default), 'invariant'
|
||||
#security_analyzer = "llm"
|
||||
|
||||
# Whether to enable security analyzer
|
||||
#enable_security_analyzer = false
|
||||
#enable_security_analyzer = true
|
||||
|
||||
#################################### Condenser #################################
|
||||
# Condensers control how conversation history is managed and compressed when
|
||||
|
||||
@@ -58,34 +58,34 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
|
||||
# Default is 60000, but we've seen up to 200000
|
||||
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
|
||||
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID app
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID openhands
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
usermod -aG app openhands && \
|
||||
usermod -aG openhands openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
RUN chown -R openhands:app /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
USER openhands
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH='/app'
|
||||
|
||||
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
# openhands:openhands -> openhands:app
|
||||
RUN find /app \! -group app -exec chgrp app {} +
|
||||
# openhands:openhands -> openhands:openhands
|
||||
RUN find /app \! -group openhands -exec chgrp openhands {} +
|
||||
|
||||
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ else
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
usermod -aG app enduser
|
||||
usermod -aG openhands enduser
|
||||
# get the user group of /var/run/docker.sock and set openhands to that group
|
||||
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
echo "Docker socket group id: $DOCKER_SOCKET_GID"
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -153,7 +153,9 @@
|
||||
"group": "Architecture",
|
||||
"pages": [
|
||||
"usage/architecture/backend",
|
||||
"usage/architecture/runtime"
|
||||
"usage/architecture/runtime",
|
||||
"usage/architecture/auth-system-summary",
|
||||
"usage/architecture/auth-system-design"
|
||||
]
|
||||
},
|
||||
"usage/how-to/debugging",
|
||||
|
||||
856
docs/usage/architecture/auth-system-design.md
Normal file
856
docs/usage/architecture/auth-system-design.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# OpenHands AuthSystem Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document proposes a comprehensive AuthSystem design for OpenHands that supports three authentication strategies: **None** (current behavior), **Single User (SU)** with GitHub OAuth, and **Multi User (MU)** (for custom builds). The design introduces clean abstraction boundaries, eliminates scattered `user_id` threading, and provides a foundation for future authentication enhancements.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **No Auth Strategy Abstraction**: OpenHands currently has a monolithic `DefaultUserAuth` that always returns `None` for `user_id`, with no clear path to support different authentication modes.
|
||||
|
||||
2. **Scattered user_id Threading**: 339+ occurrences of `user_id` across 68 files, with complex threading through:
|
||||
- Storage partitioning (`users/{user_id}/` paths)
|
||||
- Conversation/session scoping
|
||||
- API route dependencies
|
||||
- Provider token resolution
|
||||
- Data model fields
|
||||
|
||||
3. **Provider Token Pollution**: Routes accept `provider_tokens` parameters and thread them through `ProviderHandler`, creating security risks and complex signatures.
|
||||
|
||||
4. **No Single User Support**: No way to enable GitHub OAuth for personal/single-user deployments while maintaining the simplicity of the current "None" mode.
|
||||
|
||||
5. **Boundary Violations**: Auth concerns are mixed with business logic throughout the codebase, making it difficult to switch between authentication modes.
|
||||
|
||||
### Requirements from GitHub Issues
|
||||
|
||||
From **Issue #10751** (user_id audit):
|
||||
- Support None, SU, and MU modes
|
||||
- Introduce `UserContext` and `StorageNamespace` abstractions
|
||||
- Remove redundant `if user_id` guards (7 identified)
|
||||
- Clean up storage path helpers
|
||||
|
||||
From **Issue #10730** (token provider):
|
||||
- Remove `provider_tokens` dependency injection
|
||||
- Introduce `TokenProvider` boundary abstraction
|
||||
- Support backend-only credential resolution
|
||||
- Enable custom builds with token refresh/rotation patterns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. AuthStrategy Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class AuthStrategy(ABC):
|
||||
"""Base class for authentication strategies"""
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Return strategy name for logging/debugging"""
|
||||
|
||||
@abstractmethod
|
||||
def requires_auth(self) -> bool:
|
||||
"""Whether this strategy requires user authentication"""
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
"""Authenticate request and return UserContext or None"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
"""Get token provider for this request"""
|
||||
|
||||
@abstractmethod
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
"""Get login URL for frontend, None if no auth required"""
|
||||
```
|
||||
|
||||
#### 2. UserContext
|
||||
|
||||
```python
|
||||
# openhands/auth/user_context.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
"""Immutable user context for authenticated requests"""
|
||||
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
github_id: Optional[int] = None
|
||||
github_username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def storage_namespace(self) -> str:
|
||||
"""Get storage namespace for this user"""
|
||||
return self.user_id
|
||||
```
|
||||
|
||||
#### 3. TokenProvider Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/token_provider.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Mapping
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
class TokenProvider(ABC):
|
||||
"""Abstract token provider for git integrations"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
"""Get token for specific provider"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_all_tokens(self) -> Mapping[ProviderType, ProviderToken]:
|
||||
"""Get all available provider tokens"""
|
||||
```
|
||||
|
||||
#### 4. StorageNamespace
|
||||
|
||||
```python
|
||||
# openhands/auth/storage_namespace.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
"""Encapsulates storage path logic for user data"""
|
||||
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
|
||||
def get_conversation_events_dir(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}events/'
|
||||
|
||||
def get_conversation_metadata_filename(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}metadata.json'
|
||||
|
||||
# ... other path methods
|
||||
```
|
||||
|
||||
### Authentication Strategies
|
||||
|
||||
#### 1. None Strategy (Current Behavior)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/none_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, DefaultTokenProvider
|
||||
|
||||
class NoneStrategy(AuthStrategy):
|
||||
"""No authentication - current OpenHands behavior"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "none"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return False
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
return None # No user context
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
return DefaultTokenProvider() # Uses secrets.json
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return None
|
||||
```
|
||||
|
||||
#### 2. Single User Strategy
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/single_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, SingleUserTokenProvider
|
||||
from openhands.server.shared import server_config
|
||||
|
||||
class SingleUserStrategy(AuthStrategy):
|
||||
"""Single user with GitHub OAuth"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "single_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return server_config.enable_su_auth
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
if not self.requires_auth():
|
||||
# SU mode without auth - create virtual user
|
||||
return UserContext(
|
||||
user_id="local",
|
||||
username="local_user",
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
# Extract JWT token from cookie/header
|
||||
token = self._extract_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Validate JWT and extract user info
|
||||
user_data = self._validate_jwt(token)
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
# Verify user is allowed (if configured)
|
||||
if (server_config.su_github_username and
|
||||
user_data.get('github_username') != server_config.su_github_username):
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
return UserContext(
|
||||
user_id=user_data['github_username'],
|
||||
email=user_data.get('email'),
|
||||
username=user_data['github_username'],
|
||||
github_id=user_data.get('github_id'),
|
||||
github_username=user_data['github_username'],
|
||||
is_admin=True # SU user is always admin
|
||||
)
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
user_context = await self.authenticate(request)
|
||||
return SingleUserTokenProvider(user_context)
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
if not self.requires_auth():
|
||||
return None
|
||||
return f"/api/auth/github/login"
|
||||
```
|
||||
|
||||
#### 3. Multi User Strategy (Custom Build Extension Point)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/multi_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class MultiUserStrategy(AuthStrategy):
|
||||
"""Multi-user strategy - extension point for custom builds"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "multi_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# This would be implemented by custom builds/applications built on OH
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return "/api/auth/login"
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### 1. Updated UserAuth
|
||||
|
||||
```python
|
||||
# openhands/server/user_auth/strategy_user_auth.py
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.storage_namespace import StorageNamespace
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
|
||||
class StrategyUserAuth(UserAuth):
|
||||
"""UserAuth implementation using AuthStrategy pattern"""
|
||||
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self._storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return self.user_context.email if self.user_context else None
|
||||
|
||||
# ... other methods using storage_namespace
|
||||
```
|
||||
|
||||
#### 2. FastAPI Dependencies
|
||||
|
||||
```python
|
||||
# openhands/server/dependencies/auth.py
|
||||
from fastapi import Depends, Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.server.shared import get_auth_strategy
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> Optional[UserContext]:
|
||||
"""Get current user context"""
|
||||
return await strategy.authenticate(request)
|
||||
|
||||
async def get_token_provider(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> TokenProvider:
|
||||
"""Get token provider for current request"""
|
||||
return await strategy.get_token_provider(request)
|
||||
|
||||
async def require_auth(
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
) -> UserContext:
|
||||
"""Require authentication"""
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
```
|
||||
|
||||
#### 3. Updated Routes
|
||||
|
||||
```python
|
||||
# openhands/server/routes/git.py (AFTER)
|
||||
from fastapi import APIRouter, Depends
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.server.dependencies.auth import get_token_provider, get_current_user
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
|
||||
app = APIRouter(prefix='/api/user')
|
||||
|
||||
@app.get('/repositories')
|
||||
async def get_user_repositories(
|
||||
sort: str = "pushed",
|
||||
selected_provider: ProviderType | None = None,
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
):
|
||||
"""Get user repositories - no provider_tokens parameter!"""
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(sort, selected_provider)
|
||||
```
|
||||
|
||||
## Before/After Code Comparison
|
||||
|
||||
### Before: Current Implementation
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
# ... complex logic
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/storage/locations.py
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/user_auth/default_user_auth.py
|
||||
class DefaultUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return None # Always None - no multi-tenancy support
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
user_secrets = await self.get_user_secrets()
|
||||
if user_secrets is None:
|
||||
return None
|
||||
return user_secrets.provider_tokens
|
||||
```
|
||||
|
||||
### After: Proposed Implementation
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(
|
||||
sort, server_config.app_mode, selected_provider, page, per_page, installation_id
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/auth/storage_namespace.py
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/user_auth/strategy_user_auth.py
|
||||
class StrategyUserAuth(UserAuth):
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self.storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Authentication Strategy
|
||||
OH_AUTH_STRATEGY=none # Options: none, single_user, multi_user
|
||||
|
||||
# Single User Mode Settings
|
||||
OH_ENABLE_SU_AUTH=false # Enable GitHub OAuth in SU mode
|
||||
OH_SU_GITHUB_USERNAME=your_username # Restrict access to specific user
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Multi User Mode (custom build extension point)
|
||||
OH_MU_ADMIN_USERNAME=admin_user
|
||||
```
|
||||
|
||||
### Configuration Modes
|
||||
|
||||
#### 1. None Mode (Current Default)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=none
|
||||
# No additional config needed
|
||||
```
|
||||
|
||||
#### 2. Single User - No Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
```
|
||||
|
||||
#### 3. Single User - GitHub Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Implementation Benefits
|
||||
|
||||
### 1. Clean Separation of Concerns
|
||||
- Auth logic isolated in strategy classes
|
||||
- Business logic doesn't need to know about user_id
|
||||
- Clear boundaries between auth and core functionality
|
||||
|
||||
### 2. Reduced Complexity
|
||||
- Eliminates 7 redundant `if user_id` guards
|
||||
- Removes provider_tokens dependency injection
|
||||
- Simplifies method signatures throughout codebase
|
||||
|
||||
### 3. Forward Compatibility
|
||||
- custom builds can extend with custom strategies
|
||||
- Token refresh/rotation support built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
|
||||
### 4. Security Improvements
|
||||
- Tokens never exposed in route parameters
|
||||
- Centralized token management
|
||||
- Immutable user context prevents tampering
|
||||
|
||||
### 5. Developer Experience
|
||||
- Clear configuration options
|
||||
- Easy mode switching
|
||||
- Consistent patterns across codebase
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Introduce auth strategy interfaces
|
||||
2. Add UserContext and StorageNamespace
|
||||
3. Create TokenProvider abstraction
|
||||
4. Update core dependencies
|
||||
|
||||
### Phase 2: Strategy Implementation
|
||||
1. Implement NoneStrategy (backward compatible)
|
||||
2. Implement SingleUserStrategy
|
||||
3. Add configuration support
|
||||
4. Update UserAuth integration
|
||||
|
||||
### Phase 3: Route Migration
|
||||
1. Update FastAPI dependencies
|
||||
2. Remove provider_tokens dependency injection
|
||||
3. Update ProviderHandler integration
|
||||
4. Clean up redundant if-guards
|
||||
|
||||
### Phase 4: Storage Migration
|
||||
1. Replace storage path helpers
|
||||
2. Update conversation managers
|
||||
3. Migrate event stores
|
||||
4. Clean up legacy code
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Strategy implementations
|
||||
- UserContext immutability
|
||||
- StorageNamespace path generation
|
||||
- TokenProvider implementations
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end auth flows
|
||||
- Route authentication
|
||||
- Storage partitioning
|
||||
- Configuration switching
|
||||
|
||||
### Migration Tests
|
||||
- Backward compatibility
|
||||
- Data migration paths
|
||||
- Configuration validation
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### custom builds Integration Points
|
||||
```python
|
||||
# custom builds can provide their own strategies
|
||||
class custom buildsMultiUserStrategy(AuthStrategy):
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# Custom custom builds authentication logic
|
||||
pass
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
# custom builds token refresh/rotation
|
||||
return CustomBuildTokenProvider(request)
|
||||
```
|
||||
|
||||
### Additional Auth Methods
|
||||
- SAML/OIDC strategies
|
||||
- API key authentication
|
||||
- Custom JWT providers
|
||||
- Enterprise SSO integration
|
||||
|
||||
## Architecture Diagrams
|
||||
|
||||
### 1. Overall Auth System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "FastAPI Application"
|
||||
Routes[API Routes]
|
||||
Deps[FastAPI Dependencies]
|
||||
end
|
||||
|
||||
subgraph "Auth Layer"
|
||||
AuthStrategy[AuthStrategy Interface]
|
||||
NoneStrategy[NoneStrategy]
|
||||
SUStrategy[SingleUserStrategy]
|
||||
MUStrategy[MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Core Abstractions"
|
||||
UserContext[UserContext]
|
||||
TokenProvider[TokenProvider Interface]
|
||||
StorageNamespace[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Token Providers"
|
||||
DefaultTP[DefaultTokenProvider]
|
||||
SingleUserTP[SingleUserTokenProvider]
|
||||
custom buildsTP[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Storage Layer"
|
||||
SecretsStore[SecretsStore]
|
||||
SettingsStore[SettingsStore]
|
||||
ConversationStore[ConversationStore]
|
||||
end
|
||||
|
||||
Routes --> Deps
|
||||
Deps --> AuthStrategy
|
||||
AuthStrategy --> UserContext
|
||||
AuthStrategy --> TokenProvider
|
||||
UserContext --> StorageNamespace
|
||||
|
||||
NoneStrategy --> DefaultTP
|
||||
SUStrategy --> SingleUserTP
|
||||
MUStrategy --> custom buildsTP
|
||||
|
||||
TokenProvider --> SecretsStore
|
||||
StorageNamespace --> ConversationStore
|
||||
StorageNamespace --> SettingsStore
|
||||
```
|
||||
|
||||
### 2. Authentication Flow - None Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant NoneStrategy
|
||||
participant DefaultTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>NoneStrategy: authenticate(request)
|
||||
NoneStrategy->>Route: None (no user context)
|
||||
Route->>NoneStrategy: get_token_provider(request)
|
||||
NoneStrategy->>DefaultTP: create()
|
||||
DefaultTP->>SecretsStore: load secrets.json
|
||||
SecretsStore->>DefaultTP: provider tokens
|
||||
DefaultTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 3. Authentication Flow - Single User Strategy (No Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>UserContext: create virtual user (local)
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext(user_id="local")
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>SecretsStore: load user secrets
|
||||
SecretsStore->>SingleUserTP: provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 4. Authentication Flow - Single User Strategy (GitHub Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant GitHub
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant Database
|
||||
|
||||
Client->>Route: API Request with JWT cookie
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>SUStrategy: extract JWT token
|
||||
SUStrategy->>SUStrategy: validate JWT
|
||||
SUStrategy->>SUStrategy: check allowed user
|
||||
SUStrategy->>UserContext: create from JWT data
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>Database: load encrypted tokens
|
||||
Database->>SingleUserTP: encrypted provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 5. GitHub OAuth Flow - Single User Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthRoute
|
||||
participant GitHub
|
||||
participant SUStrategy
|
||||
participant Database
|
||||
participant UserContext
|
||||
|
||||
Client->>AuthRoute: GET /auth/login
|
||||
AuthRoute->>Client: GitHub OAuth URL
|
||||
Client->>GitHub: OAuth authorization
|
||||
GitHub->>AuthRoute: GET /auth/callback?code=xxx
|
||||
AuthRoute->>GitHub: exchange code for token
|
||||
GitHub->>AuthRoute: access token + user info
|
||||
AuthRoute->>SUStrategy: validate user allowed
|
||||
SUStrategy->>AuthRoute: user authorized
|
||||
AuthRoute->>Database: create/update user record
|
||||
Database->>AuthRoute: user saved
|
||||
AuthRoute->>AuthRoute: create JWT token
|
||||
AuthRoute->>Client: Set JWT cookie + redirect
|
||||
```
|
||||
|
||||
### 6. Storage Namespace Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Context"
|
||||
UC[UserContext]
|
||||
UC --> SN[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Storage Paths"
|
||||
SN --> ConvDir[get_conversation_dir]
|
||||
SN --> EventsDir[get_events_dir]
|
||||
SN --> MetaFile[get_metadata_file]
|
||||
SN --> StateFile[get_state_file]
|
||||
end
|
||||
|
||||
subgraph "Path Examples"
|
||||
ConvDir --> NonePath[sessions/sid/]
|
||||
ConvDir --> UserPath[users/user_id/conversations/sid/]
|
||||
|
||||
EventsDir --> NoneEvents[sessions/sid/events/]
|
||||
EventsDir --> UserEvents[users/user_id/conversations/sid/events/]
|
||||
end
|
||||
|
||||
subgraph "Strategy Impact"
|
||||
NoneStrategy2[NoneStrategy] --> NonePath
|
||||
SUStrategy2[SingleUserStrategy] --> UserPath
|
||||
MUStrategy2[MultiUserStrategy] --> UserPath
|
||||
end
|
||||
```
|
||||
|
||||
### 7. Token Provider Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Token Provider Interface"
|
||||
TP[TokenProvider]
|
||||
TP --> GetToken[get_token(provider)]
|
||||
TP --> GetAllTokens[get_all_tokens()]
|
||||
end
|
||||
|
||||
subgraph "Implementations"
|
||||
DefaultTP2[DefaultTokenProvider]
|
||||
SingleUserTP2[SingleUserTokenProvider]
|
||||
custom buildsTP2[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Token Sources"
|
||||
SecretsJSON[secrets.json]
|
||||
UserDB[User Database]
|
||||
Custom Build API[custom builds Token API]
|
||||
end
|
||||
|
||||
subgraph "Provider Integration"
|
||||
ProviderHandler[ProviderHandler]
|
||||
GitHubService[GitHubService]
|
||||
GitLabService[GitLabService]
|
||||
BitBucketService[BitBucketService]
|
||||
end
|
||||
|
||||
DefaultTP2 --> SecretsJSON
|
||||
SingleUserTP2 --> UserDB
|
||||
custom buildsTP2 --> Custom Build API
|
||||
|
||||
TP --> ProviderHandler
|
||||
ProviderHandler --> GitHubService
|
||||
ProviderHandler --> GitLabService
|
||||
ProviderHandler --> BitBucketService
|
||||
```
|
||||
|
||||
### 8. Configuration-Driven Strategy Selection
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Configuration"
|
||||
Config[OH_AUTH_STRATEGY]
|
||||
Config --> None[none]
|
||||
Config --> SU[single_user]
|
||||
Config --> MU[multi_user]
|
||||
end
|
||||
|
||||
subgraph "Strategy Factory"
|
||||
Factory[AuthStrategyFactory]
|
||||
Factory --> CreateNone[create NoneStrategy]
|
||||
Factory --> CreateSU[create SingleUserStrategy]
|
||||
Factory --> CreateMU[create MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Additional Config"
|
||||
SUConfig[OH_ENABLE_SU_AUTH<br/>OH_SU_GITHUB_USERNAME<br/>OH_GITHUB_CLIENT_ID]
|
||||
MUConfig[OH_MU_ADMIN_USERNAME<br/>Database Config]
|
||||
end
|
||||
|
||||
None --> CreateNone
|
||||
SU --> CreateSU
|
||||
MU --> CreateMU
|
||||
|
||||
CreateSU --> SUConfig
|
||||
CreateMU --> MUConfig
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This AuthSystem design provides OpenHands with a robust, extensible authentication foundation that:
|
||||
|
||||
1. **Maintains backward compatibility** with the current "None" mode
|
||||
2. **Enables Single User mode** with optional GitHub OAuth
|
||||
3. **Provides extension points** for custom builds with multi-user implementations
|
||||
4. **Cleans up the codebase** by removing scattered user_id threading
|
||||
5. **Improves security** by centralizing token management
|
||||
6. **Simplifies development** with clear abstractions and patterns
|
||||
|
||||
The design is ready for implementation and will significantly improve OpenHands' authentication capabilities while maintaining its current simplicity for users who don't need authentication.
|
||||
860
docs/usage/architecture/auth-system-design.mdx
Normal file
860
docs/usage/architecture/auth-system-design.mdx
Normal file
@@ -0,0 +1,860 @@
|
||||
---
|
||||
title: AuthSystem Design - Complete Specification
|
||||
---
|
||||
|
||||
# OpenHands AuthSystem Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document proposes a comprehensive AuthSystem design for OpenHands that supports three authentication strategies: **None** (current behavior), **Single User (SU)** with GitHub OAuth, and **Multi User (MU)** (for custom builds). The design introduces clean abstraction boundaries, eliminates scattered `user_id` threading, and provides a foundation for future authentication enhancements.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **No Auth Strategy Abstraction**: OpenHands currently has a monolithic `DefaultUserAuth` that always returns `None` for `user_id`, with no clear path to support different authentication modes.
|
||||
|
||||
2. **Scattered user_id Threading**: 339+ occurrences of `user_id` across 68 files, with complex threading through:
|
||||
- Storage partitioning (`users/{user_id}/` paths)
|
||||
- Conversation/session scoping
|
||||
- API route dependencies
|
||||
- Provider token resolution
|
||||
- Data model fields
|
||||
|
||||
3. **Provider Token Pollution**: Routes accept `provider_tokens` parameters and thread them through `ProviderHandler`, creating security risks and complex signatures.
|
||||
|
||||
4. **No Single User Support**: No way to enable GitHub OAuth for personal/single-user deployments while maintaining the simplicity of the current "None" mode.
|
||||
|
||||
5. **Boundary Violations**: Auth concerns are mixed with business logic throughout the codebase, making it difficult to switch between authentication modes.
|
||||
|
||||
### Requirements from GitHub Issues
|
||||
|
||||
From **Issue #10751** (user_id audit):
|
||||
- Support None, SU, and MU modes
|
||||
- Introduce `UserContext` and `StorageNamespace` abstractions
|
||||
- Remove redundant `if user_id` guards (7 identified)
|
||||
- Clean up storage path helpers
|
||||
|
||||
From **Issue #10730** (token provider):
|
||||
- Remove `provider_tokens` dependency injection
|
||||
- Introduce `TokenProvider` boundary abstraction
|
||||
- Support backend-only credential resolution
|
||||
- Enable custom builds with token refresh/rotation patterns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. AuthStrategy Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class AuthStrategy(ABC):
|
||||
"""Base class for authentication strategies"""
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Return strategy name for logging/debugging"""
|
||||
|
||||
@abstractmethod
|
||||
def requires_auth(self) -> bool:
|
||||
"""Whether this strategy requires user authentication"""
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
"""Authenticate request and return UserContext or None"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
"""Get token provider for this request"""
|
||||
|
||||
@abstractmethod
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
"""Get login URL for frontend, None if no auth required"""
|
||||
```
|
||||
|
||||
#### 2. UserContext
|
||||
|
||||
```python
|
||||
# openhands/auth/user_context.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
"""Immutable user context for authenticated requests"""
|
||||
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
github_id: Optional[int] = None
|
||||
github_username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def storage_namespace(self) -> str:
|
||||
"""Get storage namespace for this user"""
|
||||
return self.user_id
|
||||
```
|
||||
|
||||
#### 3. TokenProvider Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/token_provider.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Mapping
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
class TokenProvider(ABC):
|
||||
"""Abstract token provider for git integrations"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
"""Get token for specific provider"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_all_tokens(self) -> Mapping[ProviderType, ProviderToken]:
|
||||
"""Get all available provider tokens"""
|
||||
```
|
||||
|
||||
#### 4. StorageNamespace
|
||||
|
||||
```python
|
||||
# openhands/auth/storage_namespace.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
"""Encapsulates storage path logic for user data"""
|
||||
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
|
||||
def get_conversation_events_dir(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}events/'
|
||||
|
||||
def get_conversation_metadata_filename(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}metadata.json'
|
||||
|
||||
# ... other path methods
|
||||
```
|
||||
|
||||
### Authentication Strategies
|
||||
|
||||
#### 1. None Strategy (Current Behavior)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/none_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, DefaultTokenProvider
|
||||
|
||||
class NoneStrategy(AuthStrategy):
|
||||
"""No authentication - current OpenHands behavior"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "none"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return False
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
return None # No user context
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
return DefaultTokenProvider() # Uses secrets.json
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return None
|
||||
```
|
||||
|
||||
#### 2. Single User Strategy
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/single_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, SingleUserTokenProvider
|
||||
from openhands.server.shared import server_config
|
||||
|
||||
class SingleUserStrategy(AuthStrategy):
|
||||
"""Single user with GitHub OAuth"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "single_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return server_config.enable_su_auth
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
if not self.requires_auth():
|
||||
# SU mode without auth - create virtual user
|
||||
return UserContext(
|
||||
user_id="local",
|
||||
username="local_user",
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
# Extract JWT token from cookie/header
|
||||
token = self._extract_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Validate JWT and extract user info
|
||||
user_data = self._validate_jwt(token)
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
# Verify user is allowed (if configured)
|
||||
if (server_config.su_github_username and
|
||||
user_data.get('github_username') != server_config.su_github_username):
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
return UserContext(
|
||||
user_id=user_data['github_username'],
|
||||
email=user_data.get('email'),
|
||||
username=user_data['github_username'],
|
||||
github_id=user_data.get('github_id'),
|
||||
github_username=user_data['github_username'],
|
||||
is_admin=True # SU user is always admin
|
||||
)
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
user_context = await self.authenticate(request)
|
||||
return SingleUserTokenProvider(user_context)
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
if not self.requires_auth():
|
||||
return None
|
||||
return f"/api/auth/github/login"
|
||||
```
|
||||
|
||||
#### 3. Multi User Strategy (Custom Build Extension Point)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/multi_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class MultiUserStrategy(AuthStrategy):
|
||||
"""Multi-user strategy - extension point for custom builds"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "multi_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# This would be implemented by custom builds/applications built on OH
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return "/api/auth/login"
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### 1. Updated UserAuth
|
||||
|
||||
```python
|
||||
# openhands/server/user_auth/strategy_user_auth.py
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.storage_namespace import StorageNamespace
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
|
||||
class StrategyUserAuth(UserAuth):
|
||||
"""UserAuth implementation using AuthStrategy pattern"""
|
||||
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self._storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return self.user_context.email if self.user_context else None
|
||||
|
||||
# ... other methods using storage_namespace
|
||||
```
|
||||
|
||||
#### 2. FastAPI Dependencies
|
||||
|
||||
```python
|
||||
# openhands/server/dependencies/auth.py
|
||||
from fastapi import Depends, Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.server.shared import get_auth_strategy
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> Optional[UserContext]:
|
||||
"""Get current user context"""
|
||||
return await strategy.authenticate(request)
|
||||
|
||||
async def get_token_provider(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> TokenProvider:
|
||||
"""Get token provider for current request"""
|
||||
return await strategy.get_token_provider(request)
|
||||
|
||||
async def require_auth(
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
) -> UserContext:
|
||||
"""Require authentication"""
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
```
|
||||
|
||||
#### 3. Updated Routes
|
||||
|
||||
```python
|
||||
# openhands/server/routes/git.py (AFTER)
|
||||
from fastapi import APIRouter, Depends
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.server.dependencies.auth import get_token_provider, get_current_user
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
|
||||
app = APIRouter(prefix='/api/user')
|
||||
|
||||
@app.get('/repositories')
|
||||
async def get_user_repositories(
|
||||
sort: str = "pushed",
|
||||
selected_provider: ProviderType | None = None,
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
):
|
||||
"""Get user repositories - no provider_tokens parameter!"""
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(sort, selected_provider)
|
||||
```
|
||||
|
||||
## Before/After Code Comparison
|
||||
|
||||
### Before: Current Implementation
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
# ... complex logic
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/storage/locations.py
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/user_auth/default_user_auth.py
|
||||
class DefaultUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return None # Always None - no multi-tenancy support
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
user_secrets = await self.get_user_secrets()
|
||||
if user_secrets is None:
|
||||
return None
|
||||
return user_secrets.provider_tokens
|
||||
```
|
||||
|
||||
### After: Proposed Implementation
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(
|
||||
sort, server_config.app_mode, selected_provider, page, per_page, installation_id
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/auth/storage_namespace.py
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/user_auth/strategy_user_auth.py
|
||||
class StrategyUserAuth(UserAuth):
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self.storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Authentication Strategy
|
||||
OH_AUTH_STRATEGY=none # Options: none, single_user, multi_user
|
||||
|
||||
# Single User Mode Settings
|
||||
OH_ENABLE_SU_AUTH=false # Enable GitHub OAuth in SU mode
|
||||
OH_SU_GITHUB_USERNAME=your_username # Restrict access to specific user
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Multi User Mode (custom build extension point)
|
||||
OH_MU_ADMIN_USERNAME=admin_user
|
||||
```
|
||||
|
||||
### Configuration Modes
|
||||
|
||||
#### 1. None Mode (Current Default)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=none
|
||||
# No additional config needed
|
||||
```
|
||||
|
||||
#### 2. Single User - No Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
```
|
||||
|
||||
#### 3. Single User - GitHub Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Implementation Benefits
|
||||
|
||||
### 1. Clean Separation of Concerns
|
||||
- Auth logic isolated in strategy classes
|
||||
- Business logic doesn't need to know about user_id
|
||||
- Clear boundaries between auth and core functionality
|
||||
|
||||
### 2. Reduced Complexity
|
||||
- Eliminates 7 redundant `if user_id` guards
|
||||
- Removes provider_tokens dependency injection
|
||||
- Simplifies method signatures throughout codebase
|
||||
|
||||
### 3. Forward Compatibility
|
||||
- custom builds can extend with custom strategies
|
||||
- Token refresh/rotation support built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
|
||||
### 4. Security Improvements
|
||||
- Tokens never exposed in route parameters
|
||||
- Centralized token management
|
||||
- Immutable user context prevents tampering
|
||||
|
||||
### 5. Developer Experience
|
||||
- Clear configuration options
|
||||
- Easy mode switching
|
||||
- Consistent patterns across codebase
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Introduce auth strategy interfaces
|
||||
2. Add UserContext and StorageNamespace
|
||||
3. Create TokenProvider abstraction
|
||||
4. Update core dependencies
|
||||
|
||||
### Phase 2: Strategy Implementation
|
||||
1. Implement NoneStrategy (backward compatible)
|
||||
2. Implement SingleUserStrategy
|
||||
3. Add configuration support
|
||||
4. Update UserAuth integration
|
||||
|
||||
### Phase 3: Route Migration
|
||||
1. Update FastAPI dependencies
|
||||
2. Remove provider_tokens dependency injection
|
||||
3. Update ProviderHandler integration
|
||||
4. Clean up redundant if-guards
|
||||
|
||||
### Phase 4: Storage Migration
|
||||
1. Replace storage path helpers
|
||||
2. Update conversation managers
|
||||
3. Migrate event stores
|
||||
4. Clean up legacy code
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Strategy implementations
|
||||
- UserContext immutability
|
||||
- StorageNamespace path generation
|
||||
- TokenProvider implementations
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end auth flows
|
||||
- Route authentication
|
||||
- Storage partitioning
|
||||
- Configuration switching
|
||||
|
||||
### Migration Tests
|
||||
- Backward compatibility
|
||||
- Data migration paths
|
||||
- Configuration validation
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### custom builds Integration Points
|
||||
```python
|
||||
# custom builds can provide their own strategies
|
||||
class custom buildsMultiUserStrategy(AuthStrategy):
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# Custom custom builds authentication logic
|
||||
pass
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
# custom builds token refresh/rotation
|
||||
return CustomBuildTokenProvider(request)
|
||||
```
|
||||
|
||||
### Additional Auth Methods
|
||||
- SAML/OIDC strategies
|
||||
- API key authentication
|
||||
- Custom JWT providers
|
||||
- Enterprise SSO integration
|
||||
|
||||
## Architecture Diagrams
|
||||
|
||||
### 1. Overall Auth System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "FastAPI Application"
|
||||
Routes[API Routes]
|
||||
Deps[FastAPI Dependencies]
|
||||
end
|
||||
|
||||
subgraph "Auth Layer"
|
||||
AuthStrategy[AuthStrategy Interface]
|
||||
NoneStrategy[NoneStrategy]
|
||||
SUStrategy[SingleUserStrategy]
|
||||
MUStrategy[MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Core Abstractions"
|
||||
UserContext[UserContext]
|
||||
TokenProvider[TokenProvider Interface]
|
||||
StorageNamespace[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Token Providers"
|
||||
DefaultTP[DefaultTokenProvider]
|
||||
SingleUserTP[SingleUserTokenProvider]
|
||||
custom buildsTP[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Storage Layer"
|
||||
SecretsStore[SecretsStore]
|
||||
SettingsStore[SettingsStore]
|
||||
ConversationStore[ConversationStore]
|
||||
end
|
||||
|
||||
Routes --> Deps
|
||||
Deps --> AuthStrategy
|
||||
AuthStrategy --> UserContext
|
||||
AuthStrategy --> TokenProvider
|
||||
UserContext --> StorageNamespace
|
||||
|
||||
NoneStrategy --> DefaultTP
|
||||
SUStrategy --> SingleUserTP
|
||||
MUStrategy --> custom buildsTP
|
||||
|
||||
TokenProvider --> SecretsStore
|
||||
StorageNamespace --> ConversationStore
|
||||
StorageNamespace --> SettingsStore
|
||||
```
|
||||
|
||||
### 2. Authentication Flow - None Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant NoneStrategy
|
||||
participant DefaultTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>NoneStrategy: authenticate(request)
|
||||
NoneStrategy->>Route: None (no user context)
|
||||
Route->>NoneStrategy: get_token_provider(request)
|
||||
NoneStrategy->>DefaultTP: create()
|
||||
DefaultTP->>SecretsStore: load secrets.json
|
||||
SecretsStore->>DefaultTP: provider tokens
|
||||
DefaultTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 3. Authentication Flow - Single User Strategy (No Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>UserContext: create virtual user (local)
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext(user_id="local")
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>SecretsStore: load user secrets
|
||||
SecretsStore->>SingleUserTP: provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 4. Authentication Flow - Single User Strategy (GitHub Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant GitHub
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant Database
|
||||
|
||||
Client->>Route: API Request with JWT cookie
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>SUStrategy: extract JWT token
|
||||
SUStrategy->>SUStrategy: validate JWT
|
||||
SUStrategy->>SUStrategy: check allowed user
|
||||
SUStrategy->>UserContext: create from JWT data
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>Database: load encrypted tokens
|
||||
Database->>SingleUserTP: encrypted provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 5. GitHub OAuth Flow - Single User Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthRoute
|
||||
participant GitHub
|
||||
participant SUStrategy
|
||||
participant Database
|
||||
participant UserContext
|
||||
|
||||
Client->>AuthRoute: GET /auth/login
|
||||
AuthRoute->>Client: GitHub OAuth URL
|
||||
Client->>GitHub: OAuth authorization
|
||||
GitHub->>AuthRoute: GET /auth/callback?code=xxx
|
||||
AuthRoute->>GitHub: exchange code for token
|
||||
GitHub->>AuthRoute: access token + user info
|
||||
AuthRoute->>SUStrategy: validate user allowed
|
||||
SUStrategy->>AuthRoute: user authorized
|
||||
AuthRoute->>Database: create/update user record
|
||||
Database->>AuthRoute: user saved
|
||||
AuthRoute->>AuthRoute: create JWT token
|
||||
AuthRoute->>Client: Set JWT cookie + redirect
|
||||
```
|
||||
|
||||
### 6. Storage Namespace Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Context"
|
||||
UC[UserContext]
|
||||
UC --> SN[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Storage Paths"
|
||||
SN --> ConvDir[get_conversation_dir]
|
||||
SN --> EventsDir[get_events_dir]
|
||||
SN --> MetaFile[get_metadata_file]
|
||||
SN --> StateFile[get_state_file]
|
||||
end
|
||||
|
||||
subgraph "Path Examples"
|
||||
ConvDir --> NonePath[sessions/sid/]
|
||||
ConvDir --> UserPath[users/user_id/conversations/sid/]
|
||||
|
||||
EventsDir --> NoneEvents[sessions/sid/events/]
|
||||
EventsDir --> UserEvents[users/user_id/conversations/sid/events/]
|
||||
end
|
||||
|
||||
subgraph "Strategy Impact"
|
||||
NoneStrategy2[NoneStrategy] --> NonePath
|
||||
SUStrategy2[SingleUserStrategy] --> UserPath
|
||||
MUStrategy2[MultiUserStrategy] --> UserPath
|
||||
end
|
||||
```
|
||||
|
||||
### 7. Token Provider Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Token Provider Interface"
|
||||
TP[TokenProvider]
|
||||
TP --> GetToken[get_token(provider)]
|
||||
TP --> GetAllTokens[get_all_tokens()]
|
||||
end
|
||||
|
||||
subgraph "Implementations"
|
||||
DefaultTP2[DefaultTokenProvider]
|
||||
SingleUserTP2[SingleUserTokenProvider]
|
||||
custom buildsTP2[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Token Sources"
|
||||
SecretsJSON[secrets.json]
|
||||
UserDB[User Database]
|
||||
Custom Build API[custom builds Token API]
|
||||
end
|
||||
|
||||
subgraph "Provider Integration"
|
||||
ProviderHandler[ProviderHandler]
|
||||
GitHubService[GitHubService]
|
||||
GitLabService[GitLabService]
|
||||
BitBucketService[BitBucketService]
|
||||
end
|
||||
|
||||
DefaultTP2 --> SecretsJSON
|
||||
SingleUserTP2 --> UserDB
|
||||
custom buildsTP2 --> Custom Build API
|
||||
|
||||
TP --> ProviderHandler
|
||||
ProviderHandler --> GitHubService
|
||||
ProviderHandler --> GitLabService
|
||||
ProviderHandler --> BitBucketService
|
||||
```
|
||||
|
||||
### 8. Configuration-Driven Strategy Selection
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Configuration"
|
||||
Config[OH_AUTH_STRATEGY]
|
||||
Config --> None[none]
|
||||
Config --> SU[single_user]
|
||||
Config --> MU[multi_user]
|
||||
end
|
||||
|
||||
subgraph "Strategy Factory"
|
||||
Factory[AuthStrategyFactory]
|
||||
Factory --> CreateNone[create NoneStrategy]
|
||||
Factory --> CreateSU[create SingleUserStrategy]
|
||||
Factory --> CreateMU[create MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Additional Config"
|
||||
SUConfig[OH_ENABLE_SU_AUTH<br/>OH_SU_GITHUB_USERNAME<br/>OH_GITHUB_CLIENT_ID]
|
||||
MUConfig[OH_MU_ADMIN_USERNAME<br/>Database Config]
|
||||
end
|
||||
|
||||
None --> CreateNone
|
||||
SU --> CreateSU
|
||||
MU --> CreateMU
|
||||
|
||||
CreateSU --> SUConfig
|
||||
CreateMU --> MUConfig
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This AuthSystem design provides OpenHands with a robust, extensible authentication foundation that:
|
||||
|
||||
1. **Maintains backward compatibility** with the current "None" mode
|
||||
2. **Enables Single User mode** with optional GitHub OAuth
|
||||
3. **Provides extension points** for custom builds with multi-user implementations
|
||||
4. **Cleans up the codebase** by removing scattered user_id threading
|
||||
5. **Improves security** by centralizing token management
|
||||
6. **Simplifies development** with clear abstractions and patterns
|
||||
|
||||
The design is ready for implementation and will significantly improve OpenHands' authentication capabilities while maintaining its current simplicity for users who don't need authentication.
|
||||
210
docs/usage/architecture/auth-system-summary.md
Normal file
210
docs/usage/architecture/auth-system-summary.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# OpenHands AuthSystem Design - Executive Summary
|
||||
|
||||
## Goal
|
||||
Design a flexible authentication system for OpenHands that supports three strategies:
|
||||
- **None**: Current behavior (no auth, optional GitHub token)
|
||||
- **SU (Single User)**: GitHub OAuth for personal use
|
||||
- **MU (Multi User)**: Extension point for custom builds (not in base OH)
|
||||
|
||||
## Current Problems
|
||||
- 339+ `user_id` occurrences scattered across 68 files
|
||||
- No auth strategy abstraction
|
||||
- `provider_tokens` dependency injection complexity
|
||||
- No single-user GitHub OAuth support
|
||||
- Mixed auth/business logic concerns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
1. **AuthStrategy Interface** - Pluggable auth strategies
|
||||
2. **UserContext** - Immutable user data container
|
||||
3. **TokenProvider** - Centralized token management
|
||||
4. **StorageNamespace** - Clean storage path abstraction
|
||||
|
||||
### Auth Strategies
|
||||
```python
|
||||
# None Strategy (current behavior)
|
||||
OH_AUTH_STRATEGY=none
|
||||
|
||||
# Single User - No Auth (virtual user)
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
|
||||
# Single User - GitHub OAuth
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## 🔄 Key Changes
|
||||
|
||||
### Before (Current)
|
||||
```python
|
||||
# Route with complex dependencies
|
||||
async def get_repositories(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# Scattered path logic
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
### After (Proposed)
|
||||
```python
|
||||
# Clean route signature
|
||||
async def get_repositories(
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
|
||||
# Encapsulated storage logic
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
## Architectural Benefits
|
||||
|
||||
### Codebase Cleanup
|
||||
- Removes 7 redundant `if user_id` guards across the codebase
|
||||
- Eliminates `provider_tokens` dependency injection complexity
|
||||
- Reduces method signature complexity throughout the system
|
||||
- Centralizes storage path logic in dedicated abstractions
|
||||
|
||||
### Extensibility
|
||||
- Strategy pattern enables custom build extension points
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
- Additional auth methods can be added without refactoring
|
||||
|
||||
### Code Organization
|
||||
- Clear separation of auth and business logic
|
||||
- Consistent patterns across all authentication modes
|
||||
- Centralized token and credential management
|
||||
- Immutable user context prevents state corruption
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Auth strategy interfaces
|
||||
- [ ] UserContext & StorageNamespace
|
||||
- [ ] TokenProvider abstraction
|
||||
- [ ] Core dependencies
|
||||
|
||||
### Phase 2: Strategies
|
||||
- [ ] NoneStrategy (backward compatible)
|
||||
- [ ] SingleUserStrategy
|
||||
- [ ] Configuration support
|
||||
- [ ] UserAuth integration
|
||||
|
||||
### Phase 3: Routes
|
||||
- [ ] Update FastAPI dependencies
|
||||
- [ ] Remove provider_tokens
|
||||
- [ ] Update ProviderHandler
|
||||
- [ ] Clean redundant guards
|
||||
|
||||
### Phase 4: Storage
|
||||
- [ ] Replace path helpers
|
||||
- [ ] Update conversation managers
|
||||
- [ ] Migrate event stores
|
||||
- [ ] Legacy cleanup
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### Strategy Pattern
|
||||
```python
|
||||
class AuthStrategy(ABC):
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
pass
|
||||
```
|
||||
|
||||
### Immutable User Context
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
```
|
||||
|
||||
### Token Provider Interface
|
||||
```python
|
||||
class TokenProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
pass
|
||||
```
|
||||
|
||||
## 🔧 Configuration Examples
|
||||
|
||||
### Current Default (None)
|
||||
```bash
|
||||
# No configuration needed - maintains current behavior
|
||||
```
|
||||
|
||||
### Personal Use (SU without auth)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
# Creates virtual "local" user, uses secrets.json
|
||||
```
|
||||
|
||||
### Personal Use (SU with GitHub)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=myusername
|
||||
OH_GITHUB_CLIENT_ID=abc123
|
||||
OH_GITHUB_CLIENT_SECRET=secret456
|
||||
# Requires GitHub OAuth, restricts to specific user
|
||||
```
|
||||
|
||||
## Implementation Readiness
|
||||
|
||||
### Backward Compatibility
|
||||
- None strategy maintains exact current behavior
|
||||
- No breaking changes for existing users
|
||||
- Gradual migration path available
|
||||
|
||||
### Code Quality Improvements
|
||||
- Reduces complexity from 339 to ~50 user_id references
|
||||
- Introduces clear abstractions and boundaries
|
||||
- Enables better testing and maintainability
|
||||
|
||||
### Extensibility Foundation
|
||||
- Custom builds can add authentication strategies
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy foundation without core changes
|
||||
|
||||
## Summary
|
||||
|
||||
This design provides a clean authentication architecture for OpenHands with three key outcomes:
|
||||
|
||||
1. **Maintains simplicity** - Current users see no changes
|
||||
2. **Enables extension** - Custom builds can add authentication features
|
||||
3. **Improves codebase** - Reduces scattered auth logic and complexity
|
||||
|
||||
The architecture is well-defined with a clear migration path.
|
||||
214
docs/usage/architecture/auth-system-summary.mdx
Normal file
214
docs/usage/architecture/auth-system-summary.mdx
Normal file
@@ -0,0 +1,214 @@
|
||||
---
|
||||
title: AuthSystem Design - Executive Summary
|
||||
---
|
||||
|
||||
# OpenHands AuthSystem Design - Executive Summary
|
||||
|
||||
## Goal
|
||||
Design a flexible authentication system for OpenHands that supports three strategies:
|
||||
- **None**: Current behavior (no auth, optional GitHub token)
|
||||
- **SU (Single User)**: GitHub OAuth for personal use
|
||||
- **MU (Multi User)**: Extension point for custom builds (not in base OH)
|
||||
|
||||
## Current Problems
|
||||
- 339+ `user_id` occurrences scattered across 68 files
|
||||
- No auth strategy abstraction
|
||||
- `provider_tokens` dependency injection complexity
|
||||
- No single-user GitHub OAuth support
|
||||
- Mixed auth/business logic concerns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
1. **AuthStrategy Interface** - Pluggable auth strategies
|
||||
2. **UserContext** - Immutable user data container
|
||||
3. **TokenProvider** - Centralized token management
|
||||
4. **StorageNamespace** - Clean storage path abstraction
|
||||
|
||||
### Auth Strategies
|
||||
```python
|
||||
# None Strategy (current behavior)
|
||||
OH_AUTH_STRATEGY=none
|
||||
|
||||
# Single User - No Auth (virtual user)
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
|
||||
# Single User - GitHub OAuth
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## 🔄 Key Changes
|
||||
|
||||
### Before (Current)
|
||||
```python
|
||||
# Route with complex dependencies
|
||||
async def get_repositories(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# Scattered path logic
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
### After (Proposed)
|
||||
```python
|
||||
# Clean route signature
|
||||
async def get_repositories(
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
|
||||
# Encapsulated storage logic
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
## Architectural Benefits
|
||||
|
||||
### Codebase Cleanup
|
||||
- Removes 7 redundant `if user_id` guards across the codebase
|
||||
- Eliminates `provider_tokens` dependency injection complexity
|
||||
- Reduces method signature complexity throughout the system
|
||||
- Centralizes storage path logic in dedicated abstractions
|
||||
|
||||
### Extensibility
|
||||
- Strategy pattern enables custom build extension points
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
- Additional auth methods can be added without refactoring
|
||||
|
||||
### Code Organization
|
||||
- Clear separation of auth and business logic
|
||||
- Consistent patterns across all authentication modes
|
||||
- Centralized token and credential management
|
||||
- Immutable user context prevents state corruption
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Auth strategy interfaces
|
||||
- [ ] UserContext & StorageNamespace
|
||||
- [ ] TokenProvider abstraction
|
||||
- [ ] Core dependencies
|
||||
|
||||
### Phase 2: Strategies
|
||||
- [ ] NoneStrategy (backward compatible)
|
||||
- [ ] SingleUserStrategy
|
||||
- [ ] Configuration support
|
||||
- [ ] UserAuth integration
|
||||
|
||||
### Phase 3: Routes
|
||||
- [ ] Update FastAPI dependencies
|
||||
- [ ] Remove provider_tokens
|
||||
- [ ] Update ProviderHandler
|
||||
- [ ] Clean redundant guards
|
||||
|
||||
### Phase 4: Storage
|
||||
- [ ] Replace path helpers
|
||||
- [ ] Update conversation managers
|
||||
- [ ] Migrate event stores
|
||||
- [ ] Legacy cleanup
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### Strategy Pattern
|
||||
```python
|
||||
class AuthStrategy(ABC):
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
pass
|
||||
```
|
||||
|
||||
### Immutable User Context
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
```
|
||||
|
||||
### Token Provider Interface
|
||||
```python
|
||||
class TokenProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
pass
|
||||
```
|
||||
|
||||
## 🔧 Configuration Examples
|
||||
|
||||
### Current Default (None)
|
||||
```bash
|
||||
# No configuration needed - maintains current behavior
|
||||
```
|
||||
|
||||
### Personal Use (SU without auth)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
# Creates virtual "local" user, uses secrets.json
|
||||
```
|
||||
|
||||
### Personal Use (SU with GitHub)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=myusername
|
||||
OH_GITHUB_CLIENT_ID=abc123
|
||||
OH_GITHUB_CLIENT_SECRET=secret456
|
||||
# Requires GitHub OAuth, restricts to specific user
|
||||
```
|
||||
|
||||
## Implementation Readiness
|
||||
|
||||
### Backward Compatibility
|
||||
- None strategy maintains exact current behavior
|
||||
- No breaking changes for existing users
|
||||
- Gradual migration path available
|
||||
|
||||
### Code Quality Improvements
|
||||
- Reduces complexity from 339 to ~50 user_id references
|
||||
- Introduces clear abstractions and boundaries
|
||||
- Enables better testing and maintainability
|
||||
|
||||
### Extensibility Foundation
|
||||
- Custom builds can add authentication strategies
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy foundation without core changes
|
||||
|
||||
## Summary
|
||||
|
||||
This design provides a clean authentication architecture for OpenHands with three key outcomes:
|
||||
|
||||
1. **Maintains simplicity** - Current users see no changes
|
||||
2. **Enables extension** - Custom builds can add authentication features
|
||||
3. **Improves codebase** - Reduces scattered auth logic and complexity
|
||||
|
||||
The architecture is well-defined with a clear migration path.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Jira Data Center Integration (Beta)
|
||||
title: Jira Data Center Integration (Coming soon...)
|
||||
description: Complete guide for setting up Jira Data Center integration with OpenHands Cloud, including service account creation, personal access token generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Jira Cloud Integration
|
||||
title: Jira Cloud Integration (Coming soon...)
|
||||
description: Complete guide for setting up Jira Cloud integration with OpenHands Cloud, including service account creation, API token generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Linear Integration
|
||||
title: Linear Integration (Coming soon...)
|
||||
description: Complete guide for setting up Linear integration with OpenHands Cloud, including service account creation, API key generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Project Management Tool Integrations
|
||||
title: Project Management Tool Integrations (Coming soon...)
|
||||
description: Overview of OpenHands Cloud integrations with project management platforms including Jira Cloud, Jira Data Center, and Linear. Learn about setup requirements, usage methods, and troubleshooting.
|
||||
---
|
||||
|
||||
@@ -18,9 +18,9 @@ Integration requires two levels of setup:
|
||||
2. **Workspace Integration** - Self-service configuration through the OpenHands Cloud UI to link your OpenHands account to the target workspace
|
||||
|
||||
### Platform-Specific Setup Guides:
|
||||
- [Jira Cloud Integration](./jira-integration.md)
|
||||
- [Jira Data Center Integration](./jira-dc-integration.md)
|
||||
- [Linear Integration](./linear-integration.md)
|
||||
- [Jira Cloud Integration (Coming soon...)](./jira-integration.md)
|
||||
- [Jira Data Center Integration (Coming soon...)](./jira-dc-integration.md)
|
||||
- [Linear Integration (Coming soon...)](./linear-integration.md)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
52
docs/usage/confirmation-mode.mdx
Normal file
52
docs/usage/confirmation-mode.mdx
Normal file
@@ -0,0 +1,52 @@
|
||||
# Confirmation Mode and Security Analyzers
|
||||
|
||||
OpenHands provides a security framework to help protect users from potentially risky actions through **Confirmation Mode** and **Security Analyzers**. This system analyzes agent actions and prompts users for confirmation when high-risk operations are detected.
|
||||
|
||||
## Overview
|
||||
|
||||
The security system consists of two main components:
|
||||
|
||||
1. **Confirmation Mode**: When enabled, the agent will pause and ask for user confirmation before executing actions that are flagged as high-risk by the security analyzer.
|
||||
|
||||
2. **Security Analyzers**: These are modules that evaluate the risk level of agent actions and determine whether user confirmation is required.
|
||||
|
||||
## Configuration
|
||||
|
||||
### CLI
|
||||
In CLI mode, confirmation is enabled by default. You will have an option to uses the LLM Analyzer and will automatically confirm LOW and MEDIUM risk actions, only prompting for HIGH risk actions.
|
||||
|
||||
## Security Analyzers
|
||||
|
||||
OpenHands includes multiple analyzers:
|
||||
|
||||
- **No Analyzer**: Do not use any security analyzer. The agent will prompt you to confirm *EVERY* action.
|
||||
- **LLM Risk Analyzer** (default): Uses the same LLM as the agent to assess action risk levels
|
||||
- **Invariant Analyzer**: Uses Invariant Labs' policy engine to evaluate action traces against security policies
|
||||
|
||||
### LLM Risk Analyzer
|
||||
The default analyzer that leverages the agent's LLM to evaluate the security risk of each action. It considers the action type, parameters, and context to assign risk levels.
|
||||
|
||||
### Invariant Analyzer
|
||||
An advanced analyzer that:
|
||||
- Collects conversation events and parses them into a trace
|
||||
- Checks the trace against an Invariant policy to classify risk (low, medium, high)
|
||||
- Manages an Invariant server container automatically if needed
|
||||
- Supports optional browsing-alignment and harmful-content checks
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Action Analysis**: When the agent wants to perform an action, the selected security analyzer evaluates its risk level.
|
||||
|
||||
2. **Risk Assessment**: The analyzer returns one of three risk levels:
|
||||
- **LOW**: Action proceeds without confirmation
|
||||
- **MEDIUM**: Action proceeds without confirmation (may be configurable in future)
|
||||
- **HIGH**: Action is paused, and user confirmation is requested
|
||||
|
||||
3. **User Confirmation**: For high-risk actions, a confirmation dialog appears with:
|
||||
- Description of the action
|
||||
- Risk assessment explanation
|
||||
- Options to approve or deny action
|
||||
|
||||
4. **Action Execution**: Based on user response:
|
||||
- **Approve**: Action proceeds as planned
|
||||
- **Deny**: Action is cancelled
|
||||
@@ -87,19 +87,13 @@ source ~/.bashrc # or source ~/.zshrc
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
3. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
# If using uvx (recommended)
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<Note>
|
||||
If you have cloned the repository, you can also run the CLI directly using Poetry:
|
||||
|
||||
poetry run openhands
|
||||
</Note>
|
||||
|
||||
4. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
|
||||
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
|
||||
The first time you run the CLI, it will take you through configuring the required LLM
|
||||
@@ -119,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -128,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -45,6 +45,13 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
|
||||
|
||||
**Ubuntu (Linux Distribution)**
|
||||
|
||||
1. Install Ubuntu: `wsl --install -d Ubuntu` in PowerShell as Administrator.
|
||||
2. Restart computer when prompted.
|
||||
3. Open Ubuntu from Start menu to complete setup.
|
||||
4. Verify installation: `wsl --list` should show Ubuntu.
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
|
||||
@@ -53,7 +60,7 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
|
||||
|
||||
<Note>
|
||||
The docker command below to start the app must be run inside the WSL terminal.
|
||||
The docker command below to start the app must be run inside the WSL terminal. Use `wsl -d Ubuntu` in PowerShell or search "Ubuntu" in the Start menu to access the Ubuntu terminal.
|
||||
</Note>
|
||||
|
||||
**Alternative: Windows without WSL**
|
||||
@@ -109,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -22,7 +22,7 @@ SDK to spawn and control these sandboxes.
|
||||
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide
|
||||
[here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers`
|
||||
directory. and it's called `openhands`.
|
||||
directory, and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -38,6 +38,23 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
|
||||
### On Linux, Getting ConnectTimeout Error
|
||||
|
||||
**Description**
|
||||
|
||||
When running on Linux, you might run into the error `ERROR:root:<class 'httpx.ConnectTimeout'>: timed out`.
|
||||
|
||||
**Resolution**
|
||||
|
||||
If you installed Docker from your distribution’s package repository (e.g., docker.io on Debian/Ubuntu), be aware that
|
||||
these packages can sometimes be outdated or include changes that cause compatibility issues. try reinstalling Docker
|
||||
[using the official instructions](https://docs.docker.com/engine/install/) to ensure you are running a compatible version.
|
||||
|
||||
If that does not solve the issue, try incrementally adding the following parameters to the docker run command:
|
||||
* `--network host`
|
||||
* `-e SANDBOX_USE_HOST_NETWORK=true`
|
||||
* `-e DOCKER_HOST_ADDR=127.0.0.1`
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
**Description**
|
||||
|
||||
89
enterprise/LICENSE
Normal file
89
enterprise/LICENSE
Normal file
@@ -0,0 +1,89 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software
|
||||
to do everything you might do with the software that would
|
||||
otherwise infringe the licensor's copyright in it for any
|
||||
permitted purpose. However, you may only make changes or
|
||||
new works based on the software according to [Changes and New
|
||||
Works License](#changes-and-new-works-license), and you may
|
||||
not distribute copies of the software.
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## Free Trial
|
||||
|
||||
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
If you violate any of these terms, or do anything with the
|
||||
software not covered by your licenses, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -9,8 +9,8 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -61,18 +61,15 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
# Create config with EDA-specific container image
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
|
||||
# Override the container image for EDA
|
||||
config.sandbox.base_container_image = 'python:3.12-bookworm'
|
||||
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
|
||||
@@ -17,8 +17,8 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -41,19 +41,12 @@ from openhands.utils.async_utils import call_async_from_sync
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-slim'
|
||||
# Create config with agent_bench-specific container image
|
||||
config = get_openhands_config_for_eval(metadata=metadata)
|
||||
|
||||
# Override the container image for agent_bench
|
||||
config.sandbox.base_container_image = 'python:3.12-slim'
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
|
||||
@@ -18,6 +18,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -50,15 +51,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.11-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
sandbox_config=sandbox_config,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -61,15 +62,10 @@ def get_config(
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = BIOCODER_BENCH_CONTAINER_IMAGE
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -19,6 +19,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -75,15 +76,10 @@ def get_config(
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -40,14 +41,8 @@ def get_config(
|
||||
)
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata, runtime='docker', sandbox_config=sandbox_config
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -17,6 +17,7 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -114,16 +115,11 @@ def get_config(
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = base_container_image
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
sandbox_config=sandbox_config,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -18,6 +18,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -65,15 +66,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -23,6 +23,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -60,15 +61,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'nikolaik/python-nodejs:python3.12-nodejs22'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
sandbox_config=sandbox_config,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
if metadata.agent_config:
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -43,15 +44,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -31,6 +31,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -64,15 +65,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -24,6 +24,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -85,15 +86,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -16,6 +16,7 @@ import ruamel.yaml
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
)
|
||||
from openhands.core.config import (
|
||||
@@ -37,15 +38,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -23,6 +23,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -48,15 +49,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -11,6 +11,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -52,15 +53,10 @@ def get_config(
|
||||
'$OH_INTERPRETER_PATH -m pip install scitools-pyke'
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -14,6 +14,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -58,15 +59,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'xingyaoww/od-eval-miniwob:v1.0'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -16,6 +16,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -110,15 +111,10 @@ def get_config(
|
||||
f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}'
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -27,6 +27,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -80,15 +81,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'public.ecr.aws/i5g0m1f6/ml-bench'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -23,6 +23,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_openhands_config_for_eval,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
@@ -87,13 +88,9 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> OpenHandsConfig:
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
config = OpenHandsConfig(
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -341,16 +342,11 @@ def get_config(
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -31,6 +31,7 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -174,15 +175,10 @@ def get_config(
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
|
||||
config.set_llm_config(
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -64,16 +65,10 @@ def get_config(
|
||||
sandbox_config.base_container_image = (
|
||||
'docker.io/xingyaoww/openhands-eval-scienceagentbench'
|
||||
)
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_budget_per_task=4,
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -19,6 +19,7 @@ from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_openhands_config_for_eval,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
@@ -83,13 +84,9 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> OpenHandsConfig:
|
||||
dataset_name=metadata.dataset,
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
config = OpenHandsConfig(
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -227,16 +228,11 @@ def get_config(
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
|
||||
config.set_llm_config(
|
||||
|
||||
@@ -20,6 +20,7 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -199,16 +200,11 @@ def get_config(
|
||||
'REPO_PATH': f'/workspace/{workspace_dir_name}/',
|
||||
}
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -13,6 +13,7 @@ N_RUNS=${4:-1}
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
export ITERATIVE_EVAL_MODE=false
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
DATASET="SWE-Gym/SWE-Gym" # change this to the "/SWE-Gym-Lite" if you want to rollout the lite subset
|
||||
|
||||
@@ -37,6 +37,7 @@ from evaluation.benchmarks.testgeneval.utils import load_testgeneval_dataset
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_openhands_config_for_eval,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
@@ -58,20 +59,21 @@ def get_config(instance: pd.Series) -> OpenHandsConfig:
|
||||
f'Invalid container image for instance {instance["instance_id_swebench"]}.'
|
||||
)
|
||||
logger.info(f'Using instance container image: {base_container_image}.')
|
||||
return OpenHandsConfig(
|
||||
run_as_openhands=False,
|
||||
runtime=os.environ.get('RUNTIME', 'eventstream'),
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
use_host_network=False,
|
||||
timeout=1800,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY'),
|
||||
remote_runtime_api_url=os.environ.get(
|
||||
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
|
||||
),
|
||||
|
||||
# Create custom sandbox config for testgeneval with specific requirements
|
||||
sandbox_config = SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
use_host_network=False,
|
||||
timeout=1800, # Longer timeout than default (300)
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY'),
|
||||
remote_runtime_api_url=os.environ.get(
|
||||
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
|
||||
),
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
|
||||
return get_openhands_config_for_eval(
|
||||
sandbox_config=sandbox_config,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'), # Different default runtime
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from evaluation.utils.shared import (
|
||||
assert_and_raise,
|
||||
codeact_user_response,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -126,29 +127,26 @@ def get_config(
|
||||
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
runtime=os.environ.get('RUNTIME', 'eventstream'),
|
||||
sandbox=SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=300,
|
||||
# Add platform to the sandbox config to solve issue 4401
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get(
|
||||
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
|
||||
),
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
sandbox_config = SandboxConfig(
|
||||
base_container_image=base_container_image,
|
||||
enable_auto_lint=True,
|
||||
use_host_network=False,
|
||||
# large enough timeout, since some testcases take very long to run
|
||||
timeout=300,
|
||||
# Add platform to the sandbox config to solve issue 4401
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get(
|
||||
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
keep_runtime_alive=False,
|
||||
remote_runtime_init_timeout=3600,
|
||||
)
|
||||
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
sandbox_config=sandbox_config,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -12,7 +12,10 @@ import tempfile
|
||||
import yaml
|
||||
from browsing import pre_login
|
||||
|
||||
from evaluation.utils.shared import get_default_sandbox_config_for_eval
|
||||
from evaluation.utils.shared import (
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_openhands_config_for_eval,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
LLMConfig,
|
||||
@@ -42,19 +45,17 @@ def get_config(
|
||||
sandbox_config.enable_auto_lint = True
|
||||
# If the web services are running on the host machine, this must be set to True
|
||||
sandbox_config.use_host_network = True
|
||||
config = OpenHandsConfig(
|
||||
run_as_openhands=False,
|
||||
max_budget_per_task=4,
|
||||
config = get_openhands_config_for_eval(
|
||||
max_iterations=100,
|
||||
save_trajectory_path=os.path.join(
|
||||
mount_path_on_host, f'traj_{task_short_name}.json'
|
||||
),
|
||||
sandbox=sandbox_config,
|
||||
# we mount trajectories path so that trajectories, generated by OpenHands
|
||||
# controller, can be accessible to the evaluator file in the runtime container
|
||||
sandbox_config=sandbox_config,
|
||||
workspace_mount_path=mount_path_on_host,
|
||||
workspace_mount_path_in_sandbox='/outputs',
|
||||
)
|
||||
config.save_trajectory_path = os.path.join(
|
||||
mount_path_on_host, f'traj_{task_short_name}.json'
|
||||
)
|
||||
config.max_budget_per_task = 4
|
||||
config.set_llm_config(llm_config)
|
||||
if agent_config:
|
||||
config.set_agent_config(agent_config)
|
||||
|
||||
@@ -12,6 +12,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -44,15 +45,10 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -20,6 +20,7 @@ from evaluation.utils.shared import (
|
||||
codeact_user_response,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
is_fatal_evaluation_error,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
@@ -160,16 +161,11 @@ def get_config(
|
||||
instance_id=instance['instance_id'],
|
||||
)
|
||||
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -73,16 +74,10 @@ def get_config(
|
||||
'VWA_WIKIPEDIA': f'{base_url}:8888',
|
||||
'VWA_HOMEPAGE': f'{base_url}:4399',
|
||||
}
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
attach_to_existing=True,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
|
||||
@@ -13,6 +13,7 @@ from evaluation.utils.shared import (
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -65,15 +66,10 @@ def get_config(
|
||||
'MAP': f'{base_url}:3000',
|
||||
'HOMEPAGE': f'{base_url}:4399',
|
||||
}
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
|
||||
@@ -10,6 +10,7 @@ from evaluation.utils.shared import (
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
@@ -45,18 +46,12 @@ def get_config(
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.platform = 'linux/amd64'
|
||||
config = OpenHandsConfig(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
max_iterations=metadata.max_iterations,
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
workspace_mount_path=None,
|
||||
# debug
|
||||
debug=True,
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.debug = True
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
metadata.llm_config, metadata.eval_output_dir, instance_id
|
||||
|
||||
209
evaluation/utils/scripts/aggregate_token_usage.py
Executable file
209
evaluation/utils/scripts/aggregate_token_usage.py
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to aggregate token usage metrics from LLM completion files.
|
||||
|
||||
Usage:
|
||||
python aggregate_token_usage.py <directory_path> [--input-cost <cost>] [--output-cost <cost>] [--cached-cost <cost>]
|
||||
|
||||
Arguments:
|
||||
directory_path: Path to the directory containing completion files
|
||||
--input-cost: Cost per input token (default: 0.0)
|
||||
--output-cost: Cost per output token (default: 0.0)
|
||||
--cached-cost: Cost per cached token (default: 0.0)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def aggregate_token_usage(
|
||||
directory_path, input_cost=0.0, output_cost=0.0, cached_cost=0.0
|
||||
):
|
||||
"""
|
||||
Aggregate token usage metrics from all JSON completion files in the directory.
|
||||
|
||||
Args:
|
||||
directory_path (str): Path to directory containing completion files
|
||||
input_cost (float): Cost per input token
|
||||
output_cost (float): Cost per output token
|
||||
cached_cost (float): Cost per cached token
|
||||
"""
|
||||
|
||||
# Initialize counters
|
||||
totals = {
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'cached_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'files_processed': 0,
|
||||
'files_with_errors': 0,
|
||||
'cost': 0,
|
||||
}
|
||||
|
||||
# Find all JSON files recursively
|
||||
json_files = list(Path(directory_path).rglob('*.json'))
|
||||
|
||||
print(f'Found {len(json_files)} JSON files to process...')
|
||||
|
||||
for json_file in json_files:
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Look for usage data in response or fncall_response
|
||||
usage_data = None
|
||||
if (
|
||||
'response' in data
|
||||
and isinstance(data['response'], dict)
|
||||
and 'usage' in data['response']
|
||||
):
|
||||
usage_data = data['response']['usage']
|
||||
elif (
|
||||
'fncall_response' in data
|
||||
and isinstance(data['fncall_response'], dict)
|
||||
and 'usage' in data['fncall_response']
|
||||
):
|
||||
usage_data = data['fncall_response']['usage']
|
||||
|
||||
if usage_data:
|
||||
# Extract token counts
|
||||
completion_tokens = usage_data.get('completion_tokens', 0)
|
||||
prompt_tokens = usage_data.get('prompt_tokens', 0)
|
||||
cached_tokens = usage_data.get('cached_tokens', 0)
|
||||
|
||||
# Handle cases where cached_tokens might be in prompt_tokens_details
|
||||
if cached_tokens == 0 and 'prompt_tokens_details' in usage_data:
|
||||
details = usage_data['prompt_tokens_details']
|
||||
if isinstance(details, dict) and 'cached_tokens' in details:
|
||||
cached_tokens = details.get('cached_tokens', 0) or 0
|
||||
|
||||
# Calculate non-cached input tokens
|
||||
non_cached_input = prompt_tokens - cached_tokens
|
||||
|
||||
# Update totals
|
||||
totals['input_tokens'] += non_cached_input
|
||||
totals['output_tokens'] += completion_tokens
|
||||
totals['cached_tokens'] += cached_tokens
|
||||
totals['total_tokens'] += prompt_tokens + completion_tokens
|
||||
|
||||
if 'cost' in data:
|
||||
totals['cost'] += data['cost']
|
||||
totals['files_processed'] += 1
|
||||
|
||||
# Progress indicator
|
||||
if totals['files_processed'] % 1000 == 0:
|
||||
print(f'Processed {totals["files_processed"]} files...')
|
||||
|
||||
except Exception as e:
|
||||
totals['files_with_errors'] += 1
|
||||
if totals['files_with_errors'] <= 5: # Only show first 5 errors
|
||||
print(f'Error processing {json_file}: {e}')
|
||||
|
||||
# Calculate costs
|
||||
input_cost_total = totals['input_tokens'] * input_cost
|
||||
output_cost_total = totals['output_tokens'] * output_cost
|
||||
cached_cost_total = totals['cached_tokens'] * cached_cost
|
||||
total_cost = input_cost_total + output_cost_total + cached_cost_total
|
||||
|
||||
# Print results
|
||||
print('\n' + '=' * 60)
|
||||
print('TOKEN USAGE AGGREGATION RESULTS')
|
||||
print('=' * 60)
|
||||
print(f'Files processed: {totals["files_processed"]:,}')
|
||||
print(f'Files with errors: {totals["files_with_errors"]:,}')
|
||||
print()
|
||||
print('TOKEN COUNTS:')
|
||||
print(f' Input tokens (non-cached): {totals["input_tokens"]:,}')
|
||||
print(f' Output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Cached tokens: {totals["cached_tokens"]:,}')
|
||||
print(f' Total tokens: {totals["total_tokens"]:,}')
|
||||
print(f' Total costs (based on returned value): ${totals["cost"]:.6f}')
|
||||
print()
|
||||
|
||||
if input_cost > 0 or output_cost > 0 or cached_cost > 0:
|
||||
print('COST CALCULATED BASED ON PROVIDED RATE:')
|
||||
print(
|
||||
f' Input cost: ${input_cost_total:.6f} ({totals["input_tokens"]:,} × ${input_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Output cost: ${output_cost_total:.6f} ({totals["output_tokens"]:,} × ${output_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Cached cost: ${cached_cost_total:.6f} ({totals["cached_tokens"]:,} × ${cached_cost:.6f})'
|
||||
)
|
||||
print(f' Total cost: ${total_cost:.6f}')
|
||||
print()
|
||||
|
||||
print('SUMMARY:')
|
||||
print(
|
||||
f' Total input tokens: {totals["input_tokens"] + totals["cached_tokens"]:,}'
|
||||
)
|
||||
print(f' Total output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Grand total tokens: {totals["total_tokens"]:,}')
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Aggregate token usage metrics from LLM completion files',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python aggregate_token_usage.py /path/to/completions
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002 --cached-cost 0.0000005
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory_path', help='Path to directory containing completion files'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--input-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per input token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per output token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--cached-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per cached token (default: 0.0)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate directory path
|
||||
if not os.path.exists(args.directory_path):
|
||||
print(f"Error: Directory '{args.directory_path}' does not exist.")
|
||||
return 1
|
||||
|
||||
if not os.path.isdir(args.directory_path):
|
||||
print(f"Error: '{args.directory_path}' is not a directory.")
|
||||
return 1
|
||||
|
||||
# Run aggregation
|
||||
try:
|
||||
aggregate_token_usage(
|
||||
args.directory_path, args.input_cost, args.output_cost, args.cached_cost
|
||||
)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f'Error during aggregation: {e}')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -703,3 +703,79 @@ def get_default_sandbox_config_for_eval() -> SandboxConfig:
|
||||
remote_runtime_enable_retries=True,
|
||||
remote_runtime_class='sysbox',
|
||||
)
|
||||
|
||||
|
||||
def get_openhands_config_for_eval(
|
||||
metadata: EvalMetadata | None = None,
|
||||
sandbox_config: SandboxConfig | None = None,
|
||||
runtime: str | None = None,
|
||||
max_iterations: int | None = None,
|
||||
default_agent: str | None = None,
|
||||
enable_browser: bool = False,
|
||||
workspace_base: str | None = None,
|
||||
workspace_mount_path: str | None = None,
|
||||
):
|
||||
"""Create an OpenHandsConfig with common patterns used across evaluation scripts.
|
||||
|
||||
This function provides a standardized way to create OpenHands configurations
|
||||
for evaluation runs, with sensible defaults that match the patterns used in
|
||||
most run_infer.py scripts. Individual evaluation scripts can override specific
|
||||
attributes as needed.
|
||||
|
||||
Args:
|
||||
metadata: EvalMetadata containing agent class, max iterations, etc.
|
||||
sandbox_config: Custom sandbox config. If None, uses get_default_sandbox_config_for_eval()
|
||||
runtime: Runtime type. If None, uses environment RUNTIME or 'docker'
|
||||
max_iterations: Max iterations for the agent. If None, uses metadata.max_iterations
|
||||
default_agent: Agent class name. If None, uses metadata.agent_class
|
||||
enable_browser: Whether to enable browser functionality
|
||||
workspace_base: Workspace base path. Defaults to None
|
||||
workspace_mount_path: Workspace mount path. Defaults to None
|
||||
|
||||
Returns:
|
||||
OpenHandsConfig: Configured for evaluation with eval-specific overrides applied
|
||||
"""
|
||||
# Defer import to avoid circular imports at module load time
|
||||
from openhands.core.config.openhands_config import (
|
||||
OpenHandsConfig as _OHConfig, # type: ignore
|
||||
)
|
||||
|
||||
# Use provided sandbox config or get default
|
||||
if sandbox_config is None:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
|
||||
# Extract values from metadata if provided
|
||||
if metadata is not None:
|
||||
if max_iterations is None:
|
||||
max_iterations = metadata.max_iterations
|
||||
if default_agent is None:
|
||||
default_agent = metadata.agent_class
|
||||
|
||||
# Use environment runtime or default
|
||||
if runtime is None:
|
||||
runtime = os.environ.get('RUNTIME', 'docker')
|
||||
|
||||
# Provide sensible defaults if still None
|
||||
if default_agent is None:
|
||||
default_agent = 'CodeActAgent'
|
||||
if max_iterations is None:
|
||||
max_iterations = 50
|
||||
|
||||
# Always use repo-local .eval_sessions directory (absolute path)
|
||||
eval_store = os.path.abspath(os.path.join(os.getcwd(), '.eval_sessions'))
|
||||
|
||||
# Create the base config with evaluation-specific overrides
|
||||
config = _OHConfig(
|
||||
default_agent=default_agent,
|
||||
run_as_openhands=False,
|
||||
runtime=runtime,
|
||||
max_iterations=max_iterations,
|
||||
enable_browser=enable_browser,
|
||||
sandbox=sandbox_config,
|
||||
workspace_base=workspace_base,
|
||||
workspace_mount_path=workspace_mount_path,
|
||||
file_store='local',
|
||||
file_store_path=eval_store,
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
@@ -54,12 +54,14 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "All-Hands-AI/OpenHands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,16 +101,15 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -134,23 +135,23 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -161,7 +162,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
@@ -224,6 +226,19 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "user/repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "STARTING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -244,23 +259,23 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -271,7 +286,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -288,6 +304,8 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -298,10 +316,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@@ -309,16 +327,16 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -329,7 +347,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -358,7 +377,7 @@ describe("RepoConnector", () => {
|
||||
const goToSettingsButton = await screen.findByTestId(
|
||||
"navigate-to-settings-button",
|
||||
);
|
||||
const dropdown = screen.queryByTestId("repo-dropdown");
|
||||
const dropdown = screen.queryByTestId("git-repo-dropdown");
|
||||
const launchButton = screen.queryByTestId("repo-launch-button");
|
||||
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("RepositorySelectionForm", () => {
|
||||
});
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
@@ -168,10 +168,10 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("repo-dropdown-error"),
|
||||
await screen.findByTestId("dropdown-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
screen.getByText("Failed to load data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -231,11 +231,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
@@ -270,11 +266,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,34 +37,27 @@ const selectRepository = async (repoName: string) => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByText(repoName);
|
||||
// Find the option in the dropdown (it will have role="option")
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
expect(dropdownOption).toBeInTheDocument();
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
await userEvent.click(within(dropdownMenu).getByText(repoName));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,12 +78,14 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,10 +135,10 @@ describe("HomeScreen", () => {
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
@@ -79,6 +79,35 @@ describe("Content", () => {
|
||||
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should conditionally show security analyzer based on confirmation mode", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
|
||||
|
||||
// Initially confirmation mode is false, so security analyzer should not be visible
|
||||
expect(confirmation).not.toBeChecked();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Enable confirmation mode
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).toBeChecked();
|
||||
|
||||
// Security analyzer should now be visible
|
||||
screen.getByTestId("security-analyzer-input");
|
||||
|
||||
// Disable confirmation mode again
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).not.toBeChecked();
|
||||
|
||||
// Security analyzer should be hidden again
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
@@ -107,7 +136,6 @@ describe("Content", () => {
|
||||
within(advancedForm).getByTestId("llm-api-key-input");
|
||||
within(advancedForm).getByTestId("llm-api-key-help-anchor-advanced");
|
||||
within(advancedForm).getByTestId("agent-input");
|
||||
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
|
||||
within(advancedForm).getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
@@ -130,9 +158,6 @@ describe("Content", () => {
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
|
||||
@@ -140,15 +165,7 @@ describe("Content", () => {
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
expect(agent).toHaveValue("CodeActAgent");
|
||||
expect(confirmation).not.toBeChecked();
|
||||
expect(condensor).toBeChecked();
|
||||
|
||||
// check that security analyzer is present
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(confirmation);
|
||||
screen.getByTestId("security-analyzer-input");
|
||||
});
|
||||
|
||||
it("should render the advanced form if existings settings are advanced", async () => {
|
||||
@@ -177,7 +194,7 @@ describe("Content", () => {
|
||||
agent: "CoActAgent",
|
||||
confirmation_mode: true,
|
||||
enable_default_condenser: false,
|
||||
security_analyzer: "mock-invariant",
|
||||
security_analyzer: "none",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -203,7 +220,7 @@ describe("Content", () => {
|
||||
expect(agent).toHaveValue("CoActAgent");
|
||||
expect(confirmation).toBeChecked();
|
||||
expect(condensor).not.toBeChecked();
|
||||
expect(securityAnalyzer).toHaveValue("mock-invariant");
|
||||
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -293,7 +310,7 @@ describe("Form submission", () => {
|
||||
// select security analyzer
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText("mock-invariant");
|
||||
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
@@ -306,7 +323,7 @@ describe("Form submission", () => {
|
||||
agent: "CoActAgent",
|
||||
confirmation_mode: true,
|
||||
enable_default_condenser: false,
|
||||
security_analyzer: "mock-invariant",
|
||||
security_analyzer: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -375,9 +392,11 @@ describe("Form submission", () => {
|
||||
const baseUrl = await screen.findByTestId("base-url-input");
|
||||
const apiKey = await screen.findByTestId("llm-api-key-input");
|
||||
const agent = await screen.findByTestId("agent-input");
|
||||
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
|
||||
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
|
||||
|
||||
// Confirmation mode switch is now in basic settings, always visible
|
||||
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
|
||||
|
||||
// enter custom model
|
||||
await userEvent.type(model, "-mini");
|
||||
expect(model).toHaveValue("openai/gpt-4o-mini");
|
||||
@@ -451,14 +470,17 @@ describe("Form submission", () => {
|
||||
// select security analyzer
|
||||
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText("mock-invariant");
|
||||
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
expect(securityAnalyzer).toHaveValue("mock-invariant");
|
||||
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(securityAnalyzer);
|
||||
expect(securityAnalyzer).toHaveValue("");
|
||||
// revert back to original value
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const originalSecurityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
|
||||
await userEvent.click(originalSecurityAnalyzerOption);
|
||||
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -552,7 +574,7 @@ describe("Form submission", () => {
|
||||
expect.objectContaining({
|
||||
llm_model: "openhands/claude-sonnet-4-20250514",
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
confirmation_mode: true, // Confirmation mode is now a basic setting, should be preserved
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -107,9 +107,7 @@ describe("Content", () => {
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||
);
|
||||
const button = await screen.findByTestId("connect-git-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
screen.getByTestId("git-settings-screen");
|
||||
expect(button).toHaveAttribute("href", "/settings/integrations");
|
||||
});
|
||||
|
||||
it("should render an empty table when there are no existing secrets", async () => {
|
||||
|
||||
@@ -29,23 +29,5 @@ describe("hasAdvancedSettingsSet", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("CONFIRMATION_MODE is true", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
CONFIRMATION_MODE: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("SECURITY_ANALYZER is set", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
SECURITY_ANALYZER: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
2351
frontend/package-lock.json
generated
2351
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -11,50 +11,50 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.8.0",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@react-router/node": "^7.8.2",
|
||||
"@react-router/serve": "^7.8.2",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@stripe/react-stripe-js": "^3.9.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.3",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.3.6",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.539.0",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.260.1",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.6.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-router": "^7.8.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.1.1",
|
||||
"vite": "^7.1.3",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -88,17 +88,17 @@
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.8.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@react-router/dev": "^7.8.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.7.0",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.2.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -117,16 +117,16 @@
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.4",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.4.0",
|
||||
"stripe": "^18.5.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -21,11 +21,17 @@ import {
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import {
|
||||
GitUser,
|
||||
GitRepository,
|
||||
PaginatedBranchesResponse,
|
||||
Branch,
|
||||
} from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
import { SubscriptionAccess } from "#/types/billing";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -428,6 +434,13 @@ class OpenHands {
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
@@ -567,14 +580,38 @@ class OpenHands {
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
|
||||
static async getRepositoryBranches(
|
||||
repository: string,
|
||||
page: number = 1,
|
||||
perPage: number = 30,
|
||||
): Promise<PaginatedBranchesResponse> {
|
||||
const { data } = await openHands.get<PaginatedBranchesResponse>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchRepositoryBranches(
|
||||
repository: string,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents associated with a conversation
|
||||
* @param conversationId The ID of the conversation
|
||||
@@ -726,6 +763,27 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentManagementConversations(
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
limit,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
params.page_id = pageId;
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/microagent-management/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -49,13 +49,11 @@ export interface GetConfigResponse {
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repositoryName?: string | null;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (branchName: string | null) => void;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repositoryName,
|
||||
value,
|
||||
placeholder = "Select branch...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitBranchDropdownProps) {
|
||||
const { data: branches, isLoading } = useRepositoryBranches(
|
||||
repositoryName || null,
|
||||
);
|
||||
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
branches?.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.name,
|
||||
})) || [],
|
||||
[branches],
|
||||
);
|
||||
|
||||
const hasNoBranches = !isLoading && branches && branches.length === 0;
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value || null);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
|
||||
|
||||
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
|
||||
const displayErrorMessage = hasNoBranches
|
||||
? "This repository has no branches"
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={displayPlaceholder}
|
||||
className={className}
|
||||
errorMessage={displayErrorMessage}
|
||||
disabled={isDisabled}
|
||||
isClearable={false}
|
||||
isSearchable
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitProviderDropdownProps {
|
||||
providers: Provider[];
|
||||
value?: Provider | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (provider: Provider | null) => void;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: GitProviderDropdownProps) {
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
providers.map((provider) => ({
|
||||
value: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
})),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value as Provider | null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "../../hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "../../hooks/use-debounce";
|
||||
import OpenHands from "../../api/open-hands";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import {
|
||||
ReactSelectAsyncDropdown,
|
||||
AsyncSelectOption,
|
||||
} from "./react-select-async-dropdown";
|
||||
|
||||
export interface GitRepositoryDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const debouncedSearchInput = useDebounce(searchInput, 300);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedSearchInput.startsWith("https://")) {
|
||||
const match = debouncedSearchInput.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedSearchInput;
|
||||
}
|
||||
return debouncedSearchInput;
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search query for processed input (handles URLs)
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const searchOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
searchData
|
||||
? searchData.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}))
|
||||
: [],
|
||||
[searchData],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
// First check in loaded pages
|
||||
const option = allOptions.find((opt) => opt.value === value);
|
||||
if (option) return option;
|
||||
|
||||
// If not found, check in search results
|
||||
const searchOption = searchOptions.find((opt) => opt.value === value);
|
||||
if (searchOption) return searchOption;
|
||||
|
||||
return null;
|
||||
}, [allOptions, searchOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// Update search input to trigger debounced search
|
||||
setSearchInput(inputValue);
|
||||
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
if (inputValue.length < 2) {
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle URL inputs by performing direct search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
try {
|
||||
// Perform direct search for URL-based inputs
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
return repositories.map((repo) => ({
|
||||
value: repo.full_name,
|
||||
label: repo.full_name,
|
||||
data: repo,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Fall back to local filtering if search fails
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(repoName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For regular text inputs, use hook-based search results if available
|
||||
if (searchOptions.length > 0 && processedSearchInput === inputValue) {
|
||||
return searchOptions;
|
||||
}
|
||||
|
||||
// Fallback to local filtering while search is loading
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions, searchOptions, processedSearchInput, provider],
|
||||
);
|
||||
|
||||
const handleChange = (option: AsyncSelectOption | null) => {
|
||||
if (!option) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// First check in loaded pages
|
||||
let repo = data?.pages
|
||||
?.flatMap((p) => p.data)
|
||||
.find((r) => r.id === option.value);
|
||||
|
||||
// If not found, check in search results
|
||||
if (!repo) {
|
||||
repo = searchData?.find((r) => r.id === option.value);
|
||||
}
|
||||
|
||||
onChange?.(repo);
|
||||
};
|
||||
|
||||
const handleMenuScrollToBottom = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactSelectAsyncDropdown
|
||||
testId="repo-dropdown"
|
||||
loadOptions={loadOptions}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isLoading={isLoading || isFetchingNextPage || isSearchLoading}
|
||||
cacheOptions
|
||||
defaultOptions={allOptions}
|
||||
onChange={handleChange}
|
||||
onMenuScrollToBottom={handleMenuScrollToBottom}
|
||||
/>
|
||||
{isError && (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type AsyncSelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectAsyncDropdownProps {
|
||||
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
|
||||
testId?: string;
|
||||
placeholder?: string;
|
||||
value?: AsyncSelectOption | null;
|
||||
defaultValue?: AsyncSelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isLoading?: boolean;
|
||||
cacheOptions?: boolean;
|
||||
defaultOptions?: boolean | AsyncSelectOption[];
|
||||
onChange?: (option: AsyncSelectOption | null) => void;
|
||||
onMenuScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
export function ReactSelectAsyncDropdown({
|
||||
loadOptions,
|
||||
testId,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isLoading = false,
|
||||
cacheOptions = true,
|
||||
defaultOptions = true,
|
||||
onChange,
|
||||
onMenuScrollToBottom,
|
||||
}: ReactSelectAsyncDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
|
||||
|
||||
const handleLoadOptions = useCallback(
|
||||
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
|
||||
loadOptions(inputValue)
|
||||
.then((options) => callback(options))
|
||||
.catch(() => callback([]));
|
||||
},
|
||||
[loadOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className={cn("w-full", className)}>
|
||||
<AsyncSelect
|
||||
loadOptions={handleLoadOptions}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isLoading={isLoading}
|
||||
cacheOptions={cacheOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
onChange={onChange}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import Select from "react-select";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type SelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectDropdownProps {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
value?: SelectOption | null;
|
||||
defaultValue?: SelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (option: SelectOption | null) => void;
|
||||
}
|
||||
|
||||
export function ReactSelectDropdown({
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isSearchable = true,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: ReactSelectDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isSearchable={isSearchable}
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { StylesConfig } from "react-select";
|
||||
|
||||
export interface SelectOptionBase {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
|
||||
T,
|
||||
false
|
||||
> => ({
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.125rem",
|
||||
minHeight: "2.5rem",
|
||||
padding: "0 0.5rem",
|
||||
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
}),
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: "#ECEDEE", // content
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
fontStyle: "italic",
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
singleValue: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "#454545", // tertiary
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden", // ensure menu items don't overflow rounded corners
|
||||
}),
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
padding: "0.25rem", // add some padding around menu items
|
||||
}),
|
||||
option: (provided, state) => {
|
||||
let backgroundColor = "transparent";
|
||||
if (state.isSelected) {
|
||||
backgroundColor = "#C9B974"; // primary for selected
|
||||
} else if (state.isFocused) {
|
||||
backgroundColor = "#24272E"; // base-secondary for hover/focus
|
||||
}
|
||||
|
||||
return {
|
||||
...provided,
|
||||
backgroundColor,
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
|
||||
borderRadius: "0.5rem", // rounded menu items
|
||||
margin: "0.125rem 0", // small gap between items
|
||||
"&:hover": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE",
|
||||
},
|
||||
};
|
||||
},
|
||||
clearIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
loadingIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipb
|
||||
import { anchor } from "../markdown/anchor";
|
||||
import { OpenHandsSourceType } from "#/types/core/base";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
@@ -16,6 +17,7 @@ interface ChatMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -66,17 +68,35 @@ export function ChatMessage({
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
{actions?.map((action, index) =>
|
||||
action.tooltip ? (
|
||||
<TooltipButton
|
||||
key={index}
|
||||
tooltip={action.tooltip}
|
||||
ariaLabel={action.tooltip}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
</TooltipButton>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
|
||||
@@ -72,6 +72,9 @@ const getRecallObservationContent = (event: RecallObservation): string => {
|
||||
if (event.extras.repo_instructions) {
|
||||
content += `\n\n**Repository Instructions:**\n\n${event.extras.repo_instructions}`;
|
||||
}
|
||||
if (event.extras.conversation_instructions) {
|
||||
content += `\n\n**Conversation Instructions:**\n\n${event.extras.conversation_instructions}`;
|
||||
}
|
||||
if (event.extras.additional_agent_instructions) {
|
||||
content += `\n\n**Additional Instructions:**\n\n${event.extras.additional_agent_instructions}`;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface EventMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
@@ -24,6 +25,17 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
evt !== null &&
|
||||
"error" in evt &&
|
||||
evt.error === true;
|
||||
|
||||
const isAgentStatusError = (evt: unknown): boolean =>
|
||||
isOpenHandsEvent(evt) &&
|
||||
isAgentStateChangeObservation(evt) &&
|
||||
evt.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
interface MessagesProps {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
@@ -31,8 +43,11 @@ interface MessagesProps {
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
const {
|
||||
createConversationAndSubscribe,
|
||||
isPending,
|
||||
unsubscribeFromConversation,
|
||||
} = useCreateConversationAndSubscribeMultiple();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
@@ -48,6 +63,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -93,20 +110,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
|
||||
const handleMicroagentEvent = React.useCallback(
|
||||
(socketEvent: unknown, microagentConversationId: string) => {
|
||||
// Handle error events
|
||||
const isErrorEvent = (
|
||||
evt: unknown,
|
||||
): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
evt !== null &&
|
||||
"error" in evt &&
|
||||
evt.error === true;
|
||||
|
||||
const isAgentStatusError = (evt: unknown): boolean =>
|
||||
isOpenHandsEvent(evt) &&
|
||||
isAgentStateChangeObservation(evt) &&
|
||||
evt.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
@@ -119,7 +122,11 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isAgentStateChangeObservation(socketEvent)
|
||||
) {
|
||||
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
|
||||
// Handle completion states
|
||||
if (
|
||||
socketEvent.extras.agent_state === AgentState.FINISHED ||
|
||||
socketEvent.extras.agent_state === AgentState.AWAITING_USER_INPUT
|
||||
) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
@@ -127,6 +134,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
|
||||
unsubscribeFromConversation(microagentConversationId);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
@@ -147,9 +156,27 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
unsubscribeFromConversation(microagentConversationId);
|
||||
} else {
|
||||
// For any other event, transition from WAITING to CREATING if still waiting
|
||||
setMicroagentStatuses((prev) => {
|
||||
const currentStatus = prev.find(
|
||||
(entry) => entry.conversationId === microagentConversationId,
|
||||
)?.status;
|
||||
|
||||
if (currentStatus === MicroagentStatus.WAITING) {
|
||||
return prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.CREATING }
|
||||
: statusEntry,
|
||||
);
|
||||
}
|
||||
return prev; // No change needed
|
||||
});
|
||||
}
|
||||
},
|
||||
[setMicroagentStatuses],
|
||||
[setMicroagentStatuses, unsubscribeFromConversation],
|
||||
);
|
||||
|
||||
const handleLaunchMicroagent = (
|
||||
@@ -178,13 +205,13 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
},
|
||||
onSuccessCallback: (newConversationId: string) => {
|
||||
setShowLaunchMicroagentModal(false);
|
||||
// Update status with conversation ID
|
||||
// Update status with conversation ID - start with WAITING
|
||||
setMicroagentStatuses((prev) => [
|
||||
...prev.filter((status) => status.eventId !== selectedEventId),
|
||||
{
|
||||
eventId: selectedEventId,
|
||||
conversationId: newConversationId,
|
||||
status: MicroagentStatus.CREATING,
|
||||
status: MicroagentStatus.WAITING,
|
||||
},
|
||||
]);
|
||||
},
|
||||
@@ -219,6 +246,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
@@ -76,6 +76,10 @@ export function LaunchMicroagentModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-[#A3A3A3] font-normal leading-5">
|
||||
{t("MICROAGENT$DEFINITION")}
|
||||
</span>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -19,6 +19,8 @@ export function MicroagentStatusIndicator({
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.WAITING:
|
||||
return t("MICROAGENT$STATUS_WAITING");
|
||||
case MicroagentStatus.CREATING:
|
||||
return t("MICROAGENT$STATUS_CREATING");
|
||||
case MicroagentStatus.COMPLETED:
|
||||
@@ -35,6 +37,8 @@ export function MicroagentStatusIndicator({
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.WAITING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
|
||||
@@ -10,6 +10,11 @@ interface ConversationCreatedToastProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ConversationStartingToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationCreatedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
@@ -37,6 +42,33 @@ function ConversationCreatedToast({
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationStartingToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationStartingToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>
|
||||
{t("MICROAGENT$CONVERSATION_STARTING")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationFinishedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
@@ -78,10 +110,18 @@ function ConversationErroredToast({
|
||||
errorMessage,
|
||||
onClose,
|
||||
}: ConversationErroredToastProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Check if the error message is a translation key
|
||||
const displayMessage =
|
||||
errorMessage === "MICROAGENT$UNKNOWN_ERROR"
|
||||
? t(errorMessage)
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="error" />
|
||||
<div>{errorMessage}</div>
|
||||
<div>{displayMessage}</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -136,3 +176,18 @@ export const renderConversationErroredToast = (
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationStartingToast = (conversationId: string) =>
|
||||
toast(
|
||||
(toastInstance) => (
|
||||
<ConversationStartingToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(toastInstance.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `starting-${conversationId}`,
|
||||
duration: 10000, // Show for 10 seconds or until dismissed
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,11 +7,10 @@ import { ConversationCard } from "../conversation-panel/conversation-card";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface ControlsProps {
|
||||
setSecurityOpen: (isOpen: boolean) => void;
|
||||
showSecurityLock: boolean;
|
||||
}
|
||||
|
||||
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
export function Controls({ showSecurityLock }: ControlsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
|
||||
|
||||
@@ -21,9 +20,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
<AgentControlBar />
|
||||
<AgentStatusBar />
|
||||
|
||||
{showSecurityLock && (
|
||||
<SecurityLock onClick={() => setSecurityOpen(true)} />
|
||||
)}
|
||||
{showSecurityLock && <SecurityLock />}
|
||||
</div>
|
||||
|
||||
<ConversationCard
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { IoLockClosed } from "react-icons/io5";
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SecurityLockProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
export function SecurityLock() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export function SecurityLock({ onClick }: SecurityLockProps) {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80 transition-all"
|
||||
style={{ marginRight: "8px" }}
|
||||
onClick={onClick}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="max-w-xs p-2">
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE_LOCK_TOOLTIP)}
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<IoLockClosed size={20} />
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="mr-2 cursor-pointer hover:opacity-80 transition-all"
|
||||
aria-label={t(I18nKey.SETTINGS$TITLE)}
|
||||
>
|
||||
<IoLockClosed size={20} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export function ConfirmStopModal({
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start border border-tertiary">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_PAUSE)} />
|
||||
<BaseModalDescription
|
||||
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
|
||||
description={t(I18nKey.CONVERSATION$PAUSE_WARNING)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ConversationCardContextMenu({
|
||||
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$PAUSE)} />
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import ArchivedIcon from "./state-indicators/archived.svg?react";
|
||||
import ErrorIcon from "./state-indicators/error.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import StartingIcon from "./state-indicators/starting.svg?react";
|
||||
import StoppedIcon from "./state-indicators/stopped.svg?react";
|
||||
@@ -9,6 +11,8 @@ const CONVERSATION_STATUS_INDICATORS: Record<ConversationStatus, SVGIcon> = {
|
||||
STOPPED: StoppedIcon,
|
||||
RUNNING: RunningIcon,
|
||||
STARTING: StartingIcon,
|
||||
ARCHIVED: ArchivedIcon,
|
||||
ERROR: ErrorIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#A7A9AC"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 7h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1 0 1.43-.98 2.63-2.31 2.98l1.46 1.46C20.88 15.61 22 13.95 22 12c0-2.76-2.24-5-5-5zm-1 4h-2.19l2 2H16zM2 4.27l3.11 3.11C3.29 8.12 2 9.91 2 12c0 2.76 2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1 0-1.59 1.21-2.9 2.76-3.07L8.73 11H8v2h2.73L13 15.27V17h1.73l4.01 4L20 19.74 3.27 3 2 4.27z"/><path d="M0 24V0" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e7000b"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
export interface BranchDropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredBranches: Branch[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: Branch | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function BranchDropdownMenu({
|
||||
isOpen,
|
||||
filteredBranches,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: BranchDropdownMenuProps) {
|
||||
const renderItem = (
|
||||
branch: Branch,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Branch | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={branch.name}
|
||||
item={branch}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.name === branch.name}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(branchItem) => branchItem.name}
|
||||
getItemKey={(branchItem) => branchItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<li className="px-3 py-2">
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No branches found"
|
||||
emptyMessage="No branches available"
|
||||
testId="git-branch-dropdown-empty"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-branch-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user