Compare commits

..

33 Commits

Author SHA1 Message Date
openhands d8bd9a58e2 Fix Router context error in session timeout handling
- Modified useLogoutHandler to accept appMode as a parameter instead of using useConfig
- Updated AxiosInterceptorSetup to accept appMode as a prop
- Created AppInitializers component to fetch config and initialize interceptor only after config is available
- Removed direct Router dependency from interceptor setup
2025-05-05 03:00:06 +00:00
chuckbutkus 74a9cc2e4e Merge branch 'main' into fix-sess-timeout 2025-05-04 22:39:27 -04:00
openhands a08a4caac7 Fix session timeout handling with proper React patterns
- Created a pure function in auth-utils.ts that takes appMode as parameter
- Added a new React hook in useLogoutHandler.ts to create the handler with proper dependencies
- Created a new AxiosInterceptorSetup component to set up interceptor with proper cleanup
- Updated app root component to include the interceptor setup
- Removed localStorage dependency from use-config.ts
- Simplified the axios interceptor code
2025-05-05 02:34:24 +00:00
Xingyao Wang 421b8e948d Add vscode_port option to SandboxConfig (#8268)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-05 10:11:34 +08:00
Robert Brennan 0acfc27e00 skip flaky runtime test (#8265) 2025-05-04 20:27:43 -04:00
chuckbutkus 03ca2c4ccf Merge branch 'main' into fix-session-timeout 2025-05-04 17:38:51 -04:00
Robert Brennan e0268d6075 Move CLI files (#8261) 2025-05-04 21:24:04 +00:00
chuckbutkus d7c2f8adef Merge branch 'main' into fix-session-timeout 2025-05-04 16:54:03 -04:00
Rohit Malhotra cbc0d35bf8 Add logging for failed suggested tasks attempts (#8077)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-04 16:53:43 -04:00
chuckbutkus 7c238fbcd4 Merge branch 'main' into fix-session-timeout 2025-05-04 16:42:52 -04:00
Robert Brennan 8333e5e56a skip failing mcp test (#8263) 2025-05-04 16:03:11 -04:00
Robert Brennan a9f44b0ca5 Fix git secrets (#8258)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-05-04 19:33:48 +00:00
Chase fc32efb52e Small refactor to improve (CodeAct)Agent extensibility (#8244) 2025-05-04 19:21:54 +02:00
OpenHands 2c085ae79e Fix issue #8248: [Bug]: Run pre-commit (#8249) 2025-05-04 11:00:10 +02:00
openhands cc2f999384 Fix tests by using more flexible text matching for Credits tab 2025-05-04 05:14:36 +00:00
openhands 1a744041a6 Only logout and refresh on 401 if user is logged in 2025-05-04 04:31:59 +00:00
openhands c83fbab331 Add 401 response handling to logout and refresh browser for saas mode 2025-05-04 04:10:50 +00:00
Graham Neubig 722711db3b Add OpenHands Cloud API documentation (#8127)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-05-04 00:10:56 +00:00
Robert Brennan f45f398d81 Small tweaks for mobile styles (#8228) 2025-05-03 21:42:02 +00:00
Rohit Malhotra 0bab3b62f2 (Hotfix): Forbid extraneous params on new conversation route (#8234) 2025-05-03 14:26:38 -06:00
Rohit Malhotra ae990d3cb1 [Refactor]: Split settings and secrets stores (#8213)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-03 14:43:10 -04:00
Xingyao Wang 9babd756e5 Fix settings tab clickable area by extending it beyond just the text (#8240)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-03 17:07:41 +00:00
Engel Nyst 985e20d529 [chore] Run full agent pre-commit (#8235) 2025-05-03 11:24:03 -04:00
Boxuan Li 98cb2e24ee Make tool call json decode error recoverable (#8233) 2025-05-03 15:01:32 +00:00
Chase de175dcc87 bugfix for #8187 (infinite loop when delegating) (#8189) 2025-05-02 22:49:42 +02:00
Robert Brennan 976019ce11 Fix websocket error message handling (#8227)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-02 12:56:36 -04:00
dependabot[bot] 709b6ff39a chore(deps): bump the version-all group with 5 updates (#8226)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 18:14:29 +02:00
Rohit Malhotra 767d092f8f [Fix]: Use str in place of Repository for repository param when creating new conversation (#8159)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-02 11:17:04 -04:00
dependabot[bot] 7244e5df9f chore(deps): bump the version-all group across 1 directory with 12 updates (#8224)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-05-02 15:02:11 +00:00
மனோஜ்குமார் பழனிச்சாமி dfbb968ea0 Chore: Update pull_request_template.md (#8118) 2025-05-02 15:53:09 +02:00
Xingyao Wang e4c3bbbc08 Fix: Include RecallObservation in events sent to frontend from ENVIRONMENT source (#8196)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-02 10:18:37 +00:00
Bashwara Undupitiya 6e0fbfeeda refactor: Refactor pause/resume functionality and improve state handling in CLI (#8152) 2025-05-02 12:04:35 +02:00
Ryan H. Tran 03aa5d7456 Upgrade openhands-aci to 0.2.12 (#8220) 2025-05-02 16:54:58 +07:00
129 changed files with 2998 additions and 2128 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
- [ ] This change is worth documenting at https://docs.all-hands.dev/
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
**End-user friendly description of the problem this fixes or functionality that this introduces.**
**End-user friendly description of the problem this fixes or functionality this introduces.**
---
**Give a summary of what the PR does, explaining any non-trivial design decisions.**
**Summarize what the PR does, explaining any non-trivial design decisions.**
---
**Link of any specific issues this addresses.**
**Link of any specific issues this addresses:**
+4
View File
@@ -316,6 +316,10 @@ llm_config = 'gpt3'
# Additional Docker runtime kwargs
#docker_runtime_kwargs = {}
# Specific port to use for VSCode. If not set, a random port will be chosen.
# Useful when deploying OpenHands in a remote machine where you need to expose a specific port.
#vscode_port = 41234
#################################### Security ###################################
# Configuration for security features
##############################################################################
+8
View File
@@ -0,0 +1,8 @@
{
"label": "OpenHands Cloud",
"position": 9,
"link": {
"type": "generated-index",
"description": "Documentation for OpenHands Cloud features and services."
}
}
+177
View File
@@ -0,0 +1,177 @@
# OpenHands Cloud API
OpenHands Cloud provides a REST API that allows you to programmatically interact with the service. This is useful if you easily want to kick off your own jobs from your programs in a flexible way.
This guide explains how to obtain an API key and use the API to start conversations.
For more detailed information about the API, refer to the [OpenHands API Reference](https://docs.all-hands.dev/swagger-ui/).
## Obtaining an API Key
To use the OpenHands Cloud API, you'll need to generate an API key:
1. Log in to your [OpenHands Cloud](https://app.all-hands.dev) account
2. Navigate to the [Settings page](https://app.all-hands.dev/settings)
3. Locate the "API Keys" section
4. Click "Generate New Key"
5. Give your key a descriptive name (e.g., "Development", "Production")
6. Copy the generated API key and store it securely - it will only be shown once
![API Key Generation](/img/docs/api-key-generation.png)
## API Usage
### Starting a New Conversation
To start a new conversation with OpenHands performing a task, you'll need to make a POST request to the conversation endpoint.
#### Request Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `initial_user_msg` | string | Yes | The initial message to start the conversation |
| `repository` | string | No | Git repository name to provide context in the format `owner/repo`. You must have access to the repo. |
#### Examples
<details>
<summary>cURL</summary>
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</details>
<details>
<summary>Python (with requests)</summary>
```python
import requests
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['id']}")
print(f"Status: {conversation['status']}")
```
</details>
<details>
<summary>TypeScript/JavaScript (with fetch)</summary>
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
}
}
startConversation();
```
</details>
#### Response
The API will return a JSON object with details about the created conversation:
```json
{
"status": "ok",
"conversation_id": "abc1234",
}
```
You may also receive an `AuthenticationError` if:
1. You provided an invalid API key
2. You provided the wrong repo name
3. You don't have access to the repo
### Retrieving Conversation Status
You can check the status of a conversation by making a GET request to the conversation endpoint.
#### Endpoint
```
GET https://app.all-hands.dev/api/conversations/{conversation_id}
```
#### Example
<details>
<summary>cURL</summary>
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</details>
#### Response
The response is formatted as follows:
```json
{
"conversation_id":"abc1234",
"title":"Update README.md",
"created_at":"2025-04-29T15:13:51.370706Z",
"last_updated_at":"2025-04-29T15:13:57.199210Z",
"status":"RUNNING",
"selected_repository":"yourusername/your-repo",
"trigger":"gui"
}
```
## Rate Limits
The API has a limit of 10 simultaneous conversations per account. If you need a higher limit for your use case, please contact us at [contact@all-hands.dev](mailto:contact@all-hands.dev).
If you exceed this limit, the API will return a 429 Too Many Requests response.
@@ -6,6 +6,8 @@ OpenHands Cloud is the cloud hosted version of OpenHands by All Hands AI.
OpenHands Cloud can be accessed at https://app.all-hands.dev/.
You can also interact with OpenHands Cloud programmatically using the [API](./cloud-api).
## Getting Started
After visiting OpenHands Cloud, you will be asked to connect with your GitHub or GitLab account:
+2 -2
View File
@@ -20,7 +20,7 @@ MCP configuration is defined in the `[mcp]` section of your `config.toml` file.
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
@@ -29,7 +29,7 @@ sse_servers = [
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="data-processor",
@@ -4,6 +4,38 @@
OpenHands only supports Windows via WSL. Please be sure to run all commands inside your WSL terminal.
:::
### Unable to access VS Code tab via local IP
**Description**
When accessing OpenHands through a non-localhost URL (such as a LAN IP address), the VS Code tab shows a "Forbidden" error, while other parts of the UI work fine.
**Resolution**
This happens because VS Code runs on a random high port that may not be exposed or accessible from other machines. To fix this:
1. Set a specific port for VS Code using the `SANDBOX_VSCODE_PORT` environment variable:
```bash
docker run -it --rm \
-e SANDBOX_VSCODE_PORT=41234 \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
-p 41234:41234 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:latest
```
2. Make sure to expose the same port with `-p 41234:41234` in your Docker command.
3. Alternatively, you can set this in your `config.toml` file:
```toml
[sandbox]
vscode_port = 41234
```
### Launch docker client failed
**Description**
+1 -1
View File
@@ -55,4 +55,4 @@
"node": ">=18.0"
},
"packageManager": "npm@10.5.0"
}
}
+5 -1
View File
@@ -27,7 +27,11 @@ const sidebars: SidebarsConfig = {
label: 'Openhands Cloud',
id: 'usage/cloud/openhands-cloud',
},
{
type: 'doc',
label: 'Cloud API',
id: 'usage/cloud/cloud-api',
},
{
type: 'doc',
label: 'Cloud GitHub Resolver',
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+8 -7
View File
@@ -858,14 +858,15 @@
"schema": {
"type": "object",
"properties": {
"selected_repository": {
"type": "object",
"repository": {
"type": "string",
"nullable": true,
"properties": {
"full_name": {
"type": "string"
}
}
"description": "Full name of the repository (e.g., owner/repo)"
},
"git_provider": {
"type": "string",
"nullable": true,
"description": "The Git provider (e.g., github or gitlab). If omitted, all configured providers are checked for the repository."
},
"selected_branch": {
"type": "string",
@@ -36,13 +36,12 @@ from openhands.core.config import (
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import CmdRunAction, MessageAction, FileReadAction
from openhands.events.action import CmdRunAction, FileReadAction, MessageAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.events.serialization.event import event_to_dict
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
from openhands.utils.shutdown_listener import sleep_if_should_continue
import pdb
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'true').lower() == 'true'
@@ -51,7 +50,7 @@ RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'tru
# TODO: migrate all swe-bench docker to ghcr.io/openhands
# TODO: 适应所有的语言
DOCKER_IMAGE_PREFIX = os.environ.get('EVAL_DOCKER_IMAGE_PREFIX', '')
LANGUAGE =os.environ.get('LANGUAGE', 'python')
LANGUAGE = os.environ.get('LANGUAGE', 'python')
logger.info(f'Using docker image prefix: {DOCKER_IMAGE_PREFIX}')
@@ -71,7 +70,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
# Instruction based on Anthropic's official trajectory
# https://github.com/eschluntz/swe-bench-experiments/tree/main/evaluation/verified/20241022_tools_claude-3-5-sonnet-updated/trajs
instructions = {
"python":(
'python': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -96,7 +95,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"java": (
'java': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -121,7 +120,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
" Make sure all these tests pass with your changes.\n"
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"go": (
'go': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -146,7 +145,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"c": (
'c': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -171,7 +170,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"cpp": (
'cpp': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -196,7 +195,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"javascript": (
'javascript': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -221,7 +220,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"typescript":(
'typescript': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -246,7 +245,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"rust":(
'rust': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -270,11 +269,10 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' - The functions you changed\n'
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
)
),
}
instruction = instructions.get(LANGUAGE.lower())
if instruction and RUN_WITH_BROWSING:
instruction += (
'<IMPORTANT!>\n'
@@ -284,7 +282,6 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
return instruction
# TODO: 适应所有的语言
# def get_instance_docker_image(instance_id: str) -> str:
# image_name = 'sweb.eval.x86_64.' + instance_id
@@ -307,16 +304,15 @@ def get_instance_docker_image(instance: pd.Series):
container_name = container_name.replace('/', '_m_')
instance_id = instance.get('instance_id', '')
tag_suffix = instance_id.split('-')[-1] if instance_id else ''
container_tag = f"pr-{tag_suffix}"
container_tag = f'pr-{tag_suffix}'
# pdb.set_trace()
return f"mswebench/{container_name}:{container_tag}"
return f'mswebench/{container_name}:{container_tag}'
# return "kong/insomnia:pr-8284"
# return "'sweb.eval.x86_64.local_insomnia"
# return "local_insomnia_why"
# return "local/kong-insomnia:pr-8117"
def get_config(
instance: pd.Series,
metadata: EvalMetadata,
@@ -569,7 +565,6 @@ def complete_runtime(
f'Failed to git config --global core.pager "": {str(obs)}',
)
action = CmdRunAction(command='git add -A')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -582,14 +577,14 @@ def complete_runtime(
##删除二进制文件
action = CmdRunAction(
command=f'''
command="""
for file in $(git status --porcelain | grep -E "^(M| M|\\?\\?|A| A)" | cut -c4-); do
if [ -f "$file" ] && (file "$file" | grep -q "executable" || git check-attr binary "$file" | grep -q "binary: set"); then
git rm -f "$file" 2>/dev/null || rm -f "$file"
echo "Removed: $file"
fi
done
'''
"""
)
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -626,14 +621,12 @@ def complete_runtime(
else:
assert_and_raise(False, f'Unexpected observation type: {str(obs)}')
action = FileReadAction(
path='patch.diff'
)
action = FileReadAction(path='patch.diff')
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
git_patch = obs.content
# pdb.set_trace()
# pdb.set_trace()
assert_and_raise(git_patch is not None, 'Failed to get git diff (None)')
@@ -714,12 +707,12 @@ def process_instance(
is_binary_block = False
for line in lines:
if line.startswith("diff --git "):
if line.startswith('diff --git '):
if block and not is_binary_block:
cleaned_lines.extend(block)
block = [line]
is_binary_block = False
elif "Binary files" in line:
elif 'Binary files' in line:
is_binary_block = True
block.append(line)
else:
@@ -727,7 +720,8 @@ def process_instance(
if block and not is_binary_block:
cleaned_lines.extend(block)
return "\n".join(cleaned_lines)
return '\n'.join(cleaned_lines)
git_patch = remove_binary_diffs(git_patch)
test_result = {
'git_patch': git_patch,
@@ -797,7 +791,7 @@ if __name__ == '__main__':
# so we don't need to manage file uploading to OpenHands's repo
# dataset = load_dataset(args.dataset, split=args.split)
# dataset = load_dataset(args.dataset)
dataset = load_dataset("json", data_files = args.dataset)
dataset = load_dataset('json', data_files=args.dataset)
dataset = dataset[args.split]
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
logger.info(
@@ -3,7 +3,9 @@ import json
input_file = 'XXX.jsonl'
output_file = 'YYY.jsonl'
with open(input_file, 'r', encoding='utf-8') as fin, open(output_file, 'w', encoding='utf-8') as fout:
with open(input_file, 'r', encoding='utf-8') as fin, open(
output_file, 'w', encoding='utf-8'
) as fout:
for line in fin:
line = line.strip()
if not line:
@@ -13,18 +15,22 @@ with open(input_file, 'r', encoding='utf-8') as fin, open(output_file, 'w', enco
item = data
# 提取原始数据
org = item.get("org", "")
repo = item.get("repo", "")
number = str(item.get("number", ""))
org = item.get('org', '')
repo = item.get('repo', '')
number = str(item.get('number', ''))
new_item = {}
new_item["repo"] = f"{org}/{repo}"
new_item["instance_id"] = f"{org}__{repo}-{number}"
new_item["problem_statement"] = item["resolved_issues"][0].get("title", "") + "\n" + item["resolved_issues"][0].get("body", "")
new_item["FAIL_TO_PASS"] = []
new_item["PASS_TO_PASS"] = []
new_item["base_commit"] = item['base'].get("sha","")
new_item["version"] = "0.1" # depends
new_item['repo'] = f'{org}/{repo}'
new_item['instance_id'] = f'{org}__{repo}-{number}'
new_item['problem_statement'] = (
item['resolved_issues'][0].get('title', '')
+ '\n'
+ item['resolved_issues'][0].get('body', '')
)
new_item['FAIL_TO_PASS'] = []
new_item['PASS_TO_PASS'] = []
new_item['base_commit'] = item['base'].get('sha', '')
new_item['version'] = '0.1' # depends
output_data = new_item
fout.write(json.dumps(output_data, ensure_ascii=False) + "\n")
fout.write(json.dumps(output_data, ensure_ascii=False) + '\n')
@@ -15,7 +15,7 @@ def main():
'org': groups.group(1),
'repo': groups.group(2),
'number': groups.group(3),
'fix_patch': data['test_result']['git_patch']
'fix_patch': data['test_result']['git_patch'],
}
fout.write(json.dumps(patch) + '\n')
@@ -27,7 +27,7 @@ describe("AuthModal", () => {
it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" });
@@ -49,6 +49,7 @@ describe("HomeHeader", () => {
"gui",
undefined,
undefined,
undefined,
[],
undefined,
undefined,
@@ -165,12 +165,8 @@ describe("RepoConnector", () => {
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
{
full_name: "rbren/polaris",
git_provider: "github",
id: 1,
is_public: true,
},
"rbren/polaris",
"github",
undefined,
[],
undefined,
@@ -89,7 +89,8 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalledWith(
"suggested_task",
MOCK_RESPOSITORIES[0],
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,
[],
undefined,
@@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import OpenHands from "#/api/open-hands";
import { PaymentForm } from "#/components/features/payment/payment-form";
import { AuthContext } from "#/context/auth-context";
describe("PaymentForm", () => {
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
@@ -14,18 +13,9 @@ describe("PaymentForm", () => {
const renderPaymentForm = () =>
render(<PaymentForm />, {
wrapper: ({ children }) => (
<AuthContext.Provider
value={{
providerTokensSet: ["github"],
setProviderTokensSet: vi.fn(),
providersAreSet: true,
setProvidersAreSet: vi.fn()
}}
>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthContext.Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
@@ -3,7 +3,6 @@ import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import OpenHands from "#/api/open-hands";
import { AuthContext } from "#/context/auth-context";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
@@ -16,18 +15,7 @@ const RouterStub = createRoutesStub([
]);
const renderSidebar = () =>
renderWithProviders(
<AuthContext.Provider
value={{
providerTokensSet: ["github"],
setProviderTokensSet: vi.fn(),
providersAreSet: true,
setProvidersAreSet: vi.fn()
}}
>
<RouterStub initialEntries={["/conversation/123"]} />
</AuthContext.Provider>
);
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
@@ -36,18 +24,7 @@ describe("Sidebar", () => {
vi.clearAllMocks();
});
it.skip("should fetch settings data on mount", () => {
// Mock the useConfig hook to return OSS mode
vi.spyOn(OpenHands, "getConfig").mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-github-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false
}
});
it("should fetch settings data on mount", () => {
renderSidebar();
expect(getSettingsSpy).toHaveBeenCalled();
});
@@ -43,7 +43,7 @@ const createWrapper = () => {
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
@@ -61,7 +61,7 @@ describe("AcceptTOS", () => {
it("should render a TOS checkbox that is unchecked by default", () => {
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
@@ -72,7 +72,7 @@ describe("AcceptTOS", () => {
it("should enable the continue button when the TOS checkbox is checked", async () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
@@ -96,7 +96,7 @@ describe("AcceptTOS", () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
@@ -121,7 +121,7 @@ describe("AcceptTOS", () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
@@ -133,4 +133,4 @@ describe("AcceptTOS", () => {
expect(window.location.href).toBe(externalUrl);
});
});
});
+19 -26
View File
@@ -9,6 +9,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
@@ -230,7 +231,7 @@ describe("Content", () => {
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -242,27 +243,19 @@ describe("Form submission", () => {
await userEvent.type(githubInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: { token: "test-token" },
gitlab: { token: "" },
},
}),
);
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "test-token" },
gitlab: { token: "" },
});
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: { token: "" },
gitlab: { token: "test-token" },
},
}),
);
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "test-token" },
gitlab: { token: "" },
});
});
it("should disable the button if there is no input", async () => {
@@ -346,7 +339,7 @@ describe("Form submission", () => {
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -370,7 +363,7 @@ describe("Form submission", () => {
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -386,7 +379,7 @@ describe("Form submission", () => {
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
@@ -396,7 +389,7 @@ describe("Form submission", () => {
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
@@ -404,7 +397,7 @@ describe("Form submission", () => {
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
@@ -422,18 +415,18 @@ describe("Status toasts", () => {
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
saveProvidersSpy.mockRejectedValue(new Error("Failed to save settings"));
renderGitSettingsScreen();
@@ -444,7 +437,7 @@ describe("Status toasts", () => {
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
@@ -8,7 +8,7 @@ import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import { AuthProvider, AuthContext } from "#/context/auth-context";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
@@ -16,16 +16,7 @@ const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthContext.Provider
value={{
providerTokensSet: ["github"],
setProviderTokensSet: vi.fn(),
providersAreSet: true,
setProvidersAreSet: vi.fn()
}}
>
{children}
</AuthContext.Provider>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
@@ -89,8 +89,19 @@ describe("Settings Billing", () => {
renderSettingsScreen();
// Instead of looking for exact text, we'll check if any element contains "Credits"
const navbar = await screen.findByTestId("settings-navbar");
within(navbar).getByText("Credits");
// Wait for the component to render fully
await new Promise(resolve => setTimeout(resolve, 100));
// Get all text elements and check if any contain "Credits"
const allElements = within(navbar).queryAllByText(/./i);
const hasCreditsTab = allElements.some(el =>
el.textContent && el.textContent.toLowerCase().includes("credits")
);
expect(hasCreditsTab).toBe(true);
});
it("should render the billing settings if clicking the credits item", async () => {
@@ -108,10 +119,28 @@ describe("Settings Billing", () => {
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).getByText("Credits");
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
expect(billingSection).toBeInTheDocument();
// Wait for the component to render fully
await new Promise(resolve => setTimeout(resolve, 100));
// Find all links in the navbar
const navLinks = navbar.querySelectorAll('a');
// Find the credits link by checking the href
const creditsLink = Array.from(navLinks).find(link =>
link.getAttribute('href')?.includes('/settings/credits') ||
link.textContent?.toLowerCase().includes('credits')
);
// Make sure we found the credits link
expect(creditsLink).toBeTruthy();
// Click the credits link if found
if (creditsLink) {
await user.click(creditsLink);
const billingSection = await screen.findByTestId("billing-settings");
expect(billingSection).toBeInTheDocument();
}
});
});
+21 -8
View File
@@ -118,17 +118,30 @@ describe("Settings Screen", () => {
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
// Wait for the component to render fully
await new Promise(resolve => setTimeout(resolve, 100));
// Get all text elements in the navbar
const allElements = navbar.querySelectorAll('a span');
const allText = Array.from(allElements).map(el => el.textContent?.toLowerCase() || '');
// Check that each section to include has a matching element
sectionsToInclude.forEach((section) => {
const sectionElement = within(navbar).getByText(section, {
exact: false, // case insensitive
});
expect(sectionElement).toBeInTheDocument();
const hasSection = allText.some(text =>
text.includes(section.toLowerCase())
) || Array.from(navbar.querySelectorAll('a')).some(link =>
link.getAttribute('href')?.toLowerCase().includes(section.toLowerCase())
);
expect(hasSection).toBe(true);
});
// Check that each section to exclude does not have a matching element
sectionsToExclude.forEach((section) => {
const sectionElement = within(navbar).queryByText(section, {
exact: false, // case insensitive
});
expect(sectionElement).not.toBeInTheDocument();
const hasSection = allText.some(text =>
text.includes(section.toLowerCase())
);
expect(hasSection).toBe(false);
});
});
+228 -230
View File
@@ -17,22 +17,22 @@
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.9",
"@tanstack/react-query": "^5.75.1",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.2",
"framer-motion": "^12.9.4",
"i18next": "^25.0.2",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.27",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"lucide-react": "^0.506.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.237.0",
"posthog-js": "^1.239.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -48,13 +48,13 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0",
"vite": "^6.3.3",
"vite": "^6.3.4",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
"devDependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/parser": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.52.0",
@@ -67,7 +67,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-dom": "^19.1.3",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -92,7 +92,7 @@
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.3",
"stripe": "^18.0.0",
"stripe": "^18.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
@@ -157,44 +157,44 @@
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.9",
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz",
"integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.1.tgz",
"integrity": "sha512-Q+E+rd/yBzNQhXkG+zQnF58e4zoZfBedaxwzPmicKsiK3nt8iJYrSrDbjwFFDGC4f+rPafqRaPH6TsDoSvMf7A==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz",
"integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.10",
"@babel/helper-compilation-targets": "^7.26.5",
"@babel/helper-module-transforms": "^7.26.0",
"@babel/helpers": "^7.26.10",
"@babel/parser": "^7.26.10",
"@babel/template": "^7.26.9",
"@babel/traverse": "^7.26.10",
"@babel/types": "^7.26.10",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.1",
"@babel/helper-compilation-targets": "^7.27.1",
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helpers": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/template": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -219,13 +219,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
"integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0",
"@babel/parser": "^7.27.1",
"@babel/types": "^7.27.1",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
@@ -235,26 +235,26 @@
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz",
"integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz",
"integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.9"
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.1.tgz",
"integrity": "sha512-2YaDd/Rd9E598B5+WIc8wJPmWETiiJXFYVE60oX8FDohv7rAUU3CQj+A1MgeEmcsk2+dQuEjIe/GDvig0SqL4g==",
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.26.8",
"@babel/helper-validator-option": "^7.25.9",
"@babel/compat-data": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -273,18 +273,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
"integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^7.25.9",
"@babel/helper-replace-supers": "^7.26.5",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
"@babel/traverse": "^7.27.0",
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-member-expression-to-functions": "^7.27.1",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/traverse": "^7.27.1",
"semver": "^6.3.1"
},
"engines": {
@@ -305,41 +305,41 @@
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
"integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
"integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
"integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz",
"integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9",
"@babel/traverse": "^7.25.9"
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -349,37 +349,37 @@
}
},
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz",
"integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.9"
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.26.5",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz",
"integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-replace-supers": {
"version": "7.26.5",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz",
"integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
"integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^7.25.9",
"@babel/traverse": "^7.26.5"
"@babel/helper-member-expression-to-functions": "^7.27.1",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -389,66 +389,66 @@
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz",
"integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
"integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
"integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0"
"@babel/template": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz",
"integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.0"
"@babel/types": "^7.27.1"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -458,13 +458,13 @@
}
},
"node_modules/@babel/plugin-syntax-decorators": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz",
"integrity": "sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz",
"integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -474,13 +474,13 @@
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz",
"integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
"integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -490,13 +490,13 @@
}
},
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz",
"integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -506,14 +506,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
"version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz",
"integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz",
"integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.26.0",
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -523,12 +523,12 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz",
"integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -538,12 +538,12 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz",
"integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -553,17 +553,17 @@
}
},
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz",
"integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz",
"integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-create-class-features-plugin": "^7.27.0",
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
"@babel/plugin-syntax-typescript": "^7.25.9"
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -573,17 +573,17 @@
}
},
"node_modules/@babel/preset-typescript": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz",
"integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-validator-option": "^7.25.9",
"@babel/plugin-syntax-jsx": "^7.25.9",
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-typescript": "^7.27.0"
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -593,42 +593,39 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz",
"integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0"
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
"integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.27.0",
"@babel/parser": "^7.27.0",
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/template": "^7.27.1",
"@babel/types": "^7.27.1",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
@@ -637,13 +634,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1242,9 +1239,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz",
"integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5784,9 +5781,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.74.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.9.tgz",
"integrity": "sha512-qmjXpWyigDw4SfqdSBy24FzRvpBPXlaSbl92N77lcrL+yvVQLQkf0T6bQNbTxl9IEB/SvVFhhVZoIlQvFnNuuw==",
"version": "5.75.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.0.tgz",
"integrity": "sha512-rk8KQuCdhoRkzjRVF3QxLgAfFUyS0k7+GCQjlGEpEGco+qazJ0eMH6aO1DjDjibH7/ik383nnztua3BG+lOnwg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5794,12 +5791,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.74.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.9.tgz",
"integrity": "sha512-F8xCXDQRDgsPzLzX9+d6ycNoITAIU2bycc1idZd06bt/GjN1quEJDjHvEDWZGoVn0A/ZmntVrYv6TE0kR7c7LA==",
"version": "5.75.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.1.tgz",
"integrity": "sha512-tN+gG+eXCHYm+VpmdXUP1rfE9LUrRzgYozTkBZtJV1/WFM3vwWNKQC8G6b2RKcs+2cPg+hdToZHZfjL3bF4yIQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.74.9"
"@tanstack/query-core": "5.75.0"
},
"funding": {
"type": "github",
@@ -6091,9 +6088,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
"version": "19.1.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz",
"integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -7427,9 +7424,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001715",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz",
"integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==",
"version": "1.0.30001716",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001716.tgz",
"integrity": "sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==",
"funding": [
{
"type": "opencollective",
@@ -7930,9 +7927,9 @@
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz",
"integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==",
"version": "3.42.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz",
"integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
@@ -8175,9 +8172,9 @@
}
},
"node_modules/dedent": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
"integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
"integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -8384,9 +8381,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.144",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.144.tgz",
"integrity": "sha512-eJIaMRKeAzxfBSxtjYnoIAw/tdD6VIH6tHBZepZnAbE3Gyqqs5mGN87DvcldPUbVkIljTK8pY0CMcUljP64lfQ==",
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
"integrity": "sha512-UyiO82eb9dVOx8YO3ajDf9jz2kKyt98DEITRdeLPstOEuTlLzDA4Gyq5K9he71TQziU5jUVu2OAu5N48HmQiyQ==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -9842,13 +9839,13 @@
}
},
"node_modules/framer-motion": {
"version": "12.9.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.2.tgz",
"integrity": "sha512-R0O3Jdqbfwywpm45obP+8sTgafmdEcUoShQTAV+rB5pi+Y1Px/FYL5qLLRe5tPtBdN1J4jos7M+xN2VV2oEAbQ==",
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.4.tgz",
"integrity": "sha512-yaeGDmGQ3eCQEwZ95/pRQMaSh/Q4E2CK6JYOclG/PdjyQad0MULJ+JFVV8911Fl5a6tF6o0wgW8Dpl5Qx4Adjg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.9.1",
"motion-utils": "^12.8.3",
"motion-dom": "^12.9.4",
"motion-utils": "^12.9.4",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -10588,9 +10585,9 @@
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.5.tgz",
"integrity": "sha512-OstebRKqKiQw8xEvQF5aRyUujsCatanj7Q9eo5iiH2gJpoXGZ7483ol3sVBwfqbobTQPNH1J+NAyJ1aCQoEC+w==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz",
"integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
@@ -11987,9 +11984,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.503.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.503.0.tgz",
"integrity": "sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==",
"version": "0.506.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.506.0.tgz",
"integrity": "sha512-/2znFFzlXcZKu0ANFCnxUOBV5I2e08m19PGtb6X+BcByRj8ODlGAl3wpe4LNVrDMLJ263JoIkZn7MOGK/5sXtw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -13125,18 +13122,18 @@
}
},
"node_modules/motion-dom": {
"version": "12.9.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.9.1.tgz",
"integrity": "sha512-xqXEwRLDYDTzOgXobSoWtytRtGlf7zdkRfFbrrdP7eojaGQZ5Go4OOKtgnx7uF8sAkfr1ZjMvbCJSCIT2h6fkQ==",
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.9.4.tgz",
"integrity": "sha512-25TWkQPj5I18m+qVjXGtCsxboY11DaRC5HMjd29tHKExazW4Zf4XtAagBdLpyKsVuAxEQ6cx5/E4AB21PFpLnQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.8.3"
"motion-utils": "^12.9.4"
}
},
"node_modules/motion-utils": {
"version": "12.8.3",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.8.3.tgz",
"integrity": "sha512-GYVauZEbca8/zOhEiYOY9/uJeedYQld6co/GJFKOy//0c/4lDqk0zB549sBYqqV2iMuX+uHrY1E5zd8A2L+1Lw==",
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.9.4.tgz",
"integrity": "sha512-BW3I65zeM76CMsfh3kHid9ansEJk9Qvl+K5cu4DVHKGsI52n76OJ4z2CUJUV+Mn3uEP9k1JJA3tClG0ggSrRcg==",
"license": "MIT"
},
"node_modules/mri": {
@@ -14138,9 +14135,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.237.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.237.0.tgz",
"integrity": "sha512-DyZfwDRz405cKKskL22CXvc9EpkBmuM9lCOYsZO3L1/zXu7IGiP9nNlLaxlzy7K/8mHxQ3szoy/DBSw/zXL1pw==",
"version": "1.239.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.239.0.tgz",
"integrity": "sha512-d8WTXGHmVO1FQV7wvEIan/MlN+gzdR42GHVOSoP3jWH2eiyCHCK4tX48uLZfvaEabDfuJCExdlmelWuYPAjJFw==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"core-js": "^3.38.1",
@@ -14871,12 +14868,6 @@
"node": ">=6"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -16148,17 +16139,24 @@
}
},
"node_modules/stripe": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.0.0.tgz",
"integrity": "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA==",
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.1.0.tgz",
"integrity": "sha512-MLDiniPTHqcfIT3anyBPmOEcaiDhYa7/jRaNypQ3Rt2SJnayQZBvVbFghIziUCZdltGAndm/ZxVOSw6uuSCDig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
},
"peerDependencies": {
"@types/node": ">=12.x.x"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/style-to-js": {
@@ -17178,9 +17176,9 @@
}
},
"node_modules/vite": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz",
"integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==",
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
+10 -10
View File
@@ -16,22 +16,22 @@
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.9",
"@tanstack/react-query": "^5.75.1",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.2",
"framer-motion": "^12.9.4",
"i18next": "^25.0.2",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.27",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"lucide-react": "^0.506.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.237.0",
"posthog-js": "^1.239.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -47,7 +47,7 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0",
"vite": "^6.3.3",
"vite": "^6.3.4",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
@@ -77,8 +77,8 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/parser": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.52.0",
@@ -91,7 +91,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-dom": "^19.1.3",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -116,7 +116,7 @@
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.3",
"stripe": "^18.0.0",
"stripe": "^18.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
+6 -4
View File
@@ -13,7 +13,7 @@ import {
ConversationTrigger,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
@@ -152,7 +152,8 @@ class OpenHands {
static async createConversation(
conversation_trigger: ConversationTrigger = "gui",
selectedRepository?: GitRepository,
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
imageUrls?: string[],
replayJson?: string,
@@ -160,7 +161,8 @@ class OpenHands {
): Promise<Conversation> {
const body = {
conversation_trigger,
selected_repository: selectedRepository,
repository: selectedRepository,
git_provider,
selected_branch: undefined,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
@@ -274,7 +276,7 @@ class OpenHands {
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-settings-tokens";
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
+16
View File
@@ -0,0 +1,16 @@
import { Provider, ProviderToken } from "#/types/settings";
import { openHands } from "./open-hands-axios";
import { POSTProviderTokens } from "./secrets-service.types";
export class SecretsService {
static async addGitProvider(providers: Record<Provider, ProviderToken>) {
const tokens: POSTProviderTokens = {
provider_tokens: providers,
};
const { data } = await openHands.post<boolean>(
"/api/add-git-providers",
tokens,
);
return data;
}
}
@@ -0,0 +1,5 @@
import { Provider, ProviderToken } from "#/types/settings";
export interface POSTProviderTokens {
provider_tokens: Record<Provider, ProviderToken>;
}
@@ -0,0 +1,34 @@
import { useEffect } from "react";
import { openHands } from "#/api/open-hands-axios";
import { useLogoutHandler } from "#/hooks/useLogoutHandler";
interface AxiosInterceptorSetupProps {
appMode?: string;
}
export function AxiosInterceptorSetup({ appMode }: AxiosInterceptorSetupProps) {
const handleLogoutAndRefresh = useLogoutHandler(appMode);
useEffect(() => {
const interceptor = openHands.interceptors.response.use(
(response) => response,
async (error) => {
if (
error.response &&
error.response.status === 401 &&
localStorage.getItem("providersAreSet") === "true"
) {
await handleLogoutAndRefresh();
}
return Promise.reject(error);
},
);
return () => {
openHands.interceptors.response.eject(interceptor);
};
}, [handleLogoutAndRefresh]);
return null; // It's a logical component
}
@@ -20,7 +20,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
useAutoTitle();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
<div className="flex items-center gap-2">
<AgentControlBar />
<AgentStatusBar />
@@ -164,7 +164,7 @@ export function ConversationCard({
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
"md:w-fit h-auto rounded-xl border border-[#525252]",
)}
>
<div className="flex items-center justify-between w-full">
+7 -7
View File
@@ -28,6 +28,11 @@ function AuthProvider({
initialProvidersAreSet,
);
// Update localStorage when providersAreSet changes
React.useEffect(() => {
localStorage.setItem("providersAreSet", providersAreSet.toString());
}, [providersAreSet]);
const value = React.useMemo(
() => ({
providerTokensSet,
@@ -35,12 +40,7 @@ function AuthProvider({
providersAreSet,
setProvidersAreSet,
}),
[
providerTokensSet,
providersAreSet,
setProviderTokensSet,
setProvidersAreSet,
],
[providerTokensSet, providersAreSet],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -54,4 +54,4 @@ function useAuth() {
return context;
}
export { AuthProvider, useAuth, AuthContext };
export { AuthProvider, useAuth };
+7 -4
View File
@@ -17,19 +17,22 @@ import { AuthProvider } from "./context/auth-context";
import { queryClientConfig } from "./query-client-config";
import OpenHands from "./api/open-hands";
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { AxiosInterceptorSetup } from "./components/AxiosInterceptorSetup";
function PosthogInit() {
function AppInitializers() {
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
null,
);
const [appMode, setAppMode] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
(async () => {
try {
const config = await OpenHands.getConfig();
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
setAppMode(config.APP_MODE);
} catch (error) {
displayErrorToast("Error fetching PostHog client key");
displayErrorToast("Error fetching app configuration");
}
})();
}, []);
@@ -43,7 +46,7 @@ function PosthogInit() {
}
}, [posthogClientKey]);
return null;
return appMode ? <AxiosInterceptorSetup appMode={appMode} /> : null;
}
async function prepareApp() {
@@ -70,7 +73,7 @@ prepareApp().then(() =>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<AppInitializers />
</QueryClientProvider>
</AuthProvider>
</Provider>
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
providers,
}: {
providers: Record<Provider, ProviderToken>;
}) => SecretsService.addGitProvider(providers),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
},
meta: {
disableToast: true,
},
});
};
@@ -24,13 +24,19 @@ export const useCreateConversation = () => {
conversation_trigger: ConversationTrigger;
q?: string;
selectedRepository?: GitRepository | null;
suggested_task?: SuggestedTask;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
variables.conversation_trigger,
variables.selectedRepository || undefined,
variables.selectedRepository
? variables.selectedRepository.full_name
: undefined,
variables.selectedRepository
? variables.selectedRepository.git_provider
: undefined,
variables.q,
files,
replayJson || undefined,
@@ -20,7 +20,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens: settings.provider_tokens,
};
await OpenHands.saveSettings(apiSettings);
+1 -4
View File
@@ -2,12 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAuthState } from "#/hooks/use-auth-state";
export const useBalance = () => {
const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage();
const isLikelyAuthenticated = useAuthState();
return useQuery({
queryKey: ["user", "balance"],
@@ -15,7 +13,6 @@ export const useBalance = () => {
enabled:
!isOnTosPage &&
config?.APP_MODE === "saas" &&
config?.FEATURE_FLAGS.ENABLE_BILLING &&
isLikelyAuthenticated, // Only fetch balance if user is likely authenticated
config?.FEATURE_FLAGS.ENABLE_BILLING,
});
};
-2
View File
@@ -2,8 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
// We need to fetch the config regardless of authentication state
// as it's needed to determine the app mode and other essential settings
export const useConfig = () => {
const isOnTosPage = useIsOnTosPage();
+1 -8
View File
@@ -4,25 +4,18 @@ import OpenHands from "#/api/open-hands";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAuthState } from "#/hooks/use-auth-state";
export const useIsAuthed = () => {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage();
const isLikelyAuthenticated = useAuthState();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
// Only make the API call if the user is likely authenticated
// or if we're in OSS mode (where authentication is not required)
const shouldCheckAuth =
(!!appMode && appMode === "oss") || (!!appMode && isLikelyAuthenticated);
return useQuery({
queryKey: ["user", "authenticated", providersAreSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: shouldCheckAuth && !isOnTosPage,
enabled: !!appMode && !isOnTosPage,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
retry: false,
+2 -12
View File
@@ -6,8 +6,6 @@ import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings";
import { useAuthState } from "#/hooks/use-auth-state";
import { useConfig } from "./use-config";
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
@@ -25,7 +23,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
PROVIDER_TOKENS: apiSettings.provider_tokens,
IS_NEW_USER: false,
};
};
@@ -33,15 +30,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
export const useSettings = () => {
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const isOnTosPage = useIsOnTosPage();
const isLikelyAuthenticated = useAuthState();
const { data: config } = useConfig();
// Only make the API call if the user is likely authenticated
// or if we're in OSS mode (where authentication is not required)
const appMode = config?.APP_MODE;
const shouldFetchSettings =
(!!appMode && appMode === "oss") || (!!appMode && isLikelyAuthenticated);
const isOnTosPage = useIsOnTosPage();
const query = useQuery({
queryKey: ["settings", providerTokensSet],
@@ -52,7 +42,7 @@ export const useSettings = () => {
retry: (_, error) => error.status !== 404,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage && shouldFetchSettings,
enabled: !isOnTosPage,
meta: {
disableToast: true,
},
-12
View File
@@ -1,12 +0,0 @@
import { useAuth } from "#/context/auth-context";
/**
* A hook that returns whether the user is likely authenticated based on local state.
* This is used to prevent unnecessary API calls when the user is not logged in.
*/
export const useAuthState = () => {
const { providersAreSet } = useAuth();
// If providers are set, the user is likely authenticated
return providersAreSet;
};
+5
View File
@@ -0,0 +1,5 @@
import React from "react";
import { createLogoutHandler } from "#/utils/auth-utils";
export const useLogoutHandler = (appMode?: string) =>
React.useMemo(() => createLogoutHandler(appMode), [appMode]);
-3
View File
@@ -440,9 +440,6 @@ export enum I18nKey {
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
AUTH$AUTHENTICATION_FAILED = "AUTH$AUTHENTICATION_FAILED",
AUTH$AUTHENTICATION_SUCCESSFUL = "AUTH$AUTHENTICATION_SUCCESSFUL",
AUTH$PROCESSING_AUTHENTICATION = "AUTH$PROCESSING_AUTHENTICATION",
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
-45
View File
@@ -6319,51 +6319,6 @@
"tr": "Kimlik sağlayıcınızla giriş yapın",
"de": "Melden Sie sich mit Ihrem Identitätsanbieter an"
},
"AUTH$AUTHENTICATION_FAILED": {
"en": "Authentication failed. Please try again.",
"ja": "認証に失敗しました。もう一度お試しください。",
"zh-CN": "认证失败。请重试。",
"zh-TW": "認證失敗。請重試。",
"ko-KR": "인증에 실패했습니다. 다시 시도해 주세요.",
"no": "Autentisering mislyktes. Vennligst prøv igjen.",
"it": "Autenticazione fallita. Per favore riprova.",
"pt": "Falha na autenticação. Por favor, tente novamente.",
"es": "Autenticación fallida. Por favor, inténtelo de nuevo.",
"ar": "فشل المصادقة. يرجى المحاولة مرة أخرى.",
"fr": "L'authentification a échoué. Veuillez réessayer.",
"tr": "Kimlik doğrulama başarısız oldu. Lütfen tekrar deneyin.",
"de": "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"AUTH$AUTHENTICATION_SUCCESSFUL": {
"en": "Authentication successful!",
"ja": "認証に成功しました!",
"zh-CN": "认证成功!",
"zh-TW": "認證成功!",
"ko-KR": "인증 성공!",
"no": "Autentisering vellykket!",
"it": "Autenticazione riuscita!",
"pt": "Autenticação bem-sucedida!",
"es": "¡Autenticación exitosa!",
"ar": "تمت المصادقة بنجاح!",
"fr": "Authentification réussie !",
"tr": "Kimlik doğrulama başarılı!",
"de": "Authentifizierung erfolgreich!"
},
"AUTH$PROCESSING_AUTHENTICATION": {
"en": "Processing authentication...",
"ja": "認証処理中...",
"zh-CN": "正在处理认证...",
"zh-TW": "正在處理認證...",
"ko-KR": "인증 처리 중...",
"no": "Behandler autentisering...",
"it": "Elaborazione dell'autenticazione in corso...",
"pt": "Processando autenticação...",
"es": "Procesando autenticación...",
"ar": "جاري معالجة المصادقة...",
"fr": "Traitement de l'authentification...",
"tr": "Kimlik doğrulama işleniyor...",
"de": "Authentifizierung wird verarbeitet..."
},
"WAITLIST$JOIN_WAITLIST": {
"en": "Join Waitlist",
"ja": "ウェイトリストに参加",
+29 -2
View File
@@ -6,7 +6,7 @@ import {
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
@@ -26,7 +26,6 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
provider_tokens: DEFAULT_SETTINGS.PROVIDER_TOKENS,
};
const MOCK_USER_PREFERENCES: {
@@ -293,4 +292,32 @@ export const handlers = [
MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
return HttpResponse.json(null, { status: 200 });
}),
http.post("/api/add-git-providers", async ({ request }) => {
const body = await request.json();
if (typeof body === "object" && body?.provider_tokens) {
const rawTokens = body.provider_tokens as Record<
string,
{ token?: string }
>;
const providerTokensSet: Partial<Record<Provider, string | null>> =
Object.fromEntries(
Object.entries(rawTokens)
.filter(([, val]) => val && val.token)
.map(([provider]) => [provider as Provider, ""]),
);
const newSettings = {
...(MOCK_USER_PREFERENCES.settings ?? MOCK_DEFAULT_USER_SETTINGS),
provider_tokens_set: providerTokensSet,
};
MOCK_USER_PREFERENCES.settings = newSettings;
return HttpResponse.json(true, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
];
-1
View File
@@ -9,7 +9,6 @@ export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("accept-tos", "routes/accept-tos.tsx"),
route("oauth/keycloak/callback", "routes/oauth-callback.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("git", "routes/git-settings.tsx"),
+4 -4
View File
@@ -1,6 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
@@ -16,11 +15,12 @@ import {
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
import { useAuth } from "#/context/auth-context";
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
function GitSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { mutate: saveGitProviders, isPending } = useAddGitProviders();
const { mutate: disconnectGitTokens } = useLogout();
const { providerTokensSet } = useAuth();
@@ -48,9 +48,9 @@ function GitSettingsScreen() {
const githubToken = formData.get("github-token-input")?.toString() || "";
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
saveSettings(
saveGitProviders(
{
provider_tokens: {
providers: {
github: { token: githubToken },
gitlab: { token: gitlabToken },
},
+2 -1
View File
@@ -22,10 +22,11 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex flex-col md:flex-row justify-between gap-4">
<main className="flex flex-col md:flex-row justify-between gap-8">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
<hr className="md:hidden border-[#717888]" />
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
</main>
</div>
-63
View File
@@ -1,63 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setProvidersAreSet } = useAuth();
const { t } = useTranslation();
const [isProcessing, setIsProcessing] = React.useState(true);
React.useEffect(() => {
const code = searchParams.get("code");
if (!code) {
displayErrorToast(t(I18nKey.AUTH$AUTHENTICATION_FAILED));
navigate("/");
return;
}
const processOAuthCallback = async () => {
try {
// Process the OAuth callback
await OpenHands.getGitHubAccessToken(code);
// Set authentication state
setProvidersAreSet(true);
// Show success message
displaySuccessToast(t(I18nKey.AUTH$AUTHENTICATION_SUCCESSFUL));
// Redirect to home page
navigate("/");
} catch (error) {
// Log error and show error toast
displayErrorToast(t(I18nKey.AUTH$AUTHENTICATION_FAILED));
navigate("/");
} finally {
setIsProcessing(false);
}
};
processOAuthCallback();
}, [navigate, searchParams, setProvidersAreSet, t]);
return (
<div className="flex flex-col items-center justify-center h-full">
{isProcessing && (
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary mx-auto mb-4" />
<p className="text-lg">{t(I18nKey.AUTH$PROCESSING_AUTHENTICATION)}</p>
</div>
)}
</div>
);
}
+1 -8
View File
@@ -131,23 +131,16 @@ export default function MainApp() {
}, [error?.status, pathname, isFetching, tosPageStatus]);
// When on TOS page, we don't make any API calls, so we need to handle this case
// If we haven't fetched auth status yet (because user is not logged in), consider them not authenticated
let userIsAuthed = false;
if (!tosPageStatus && !isFetchingAuth) {
userIsAuthed = !!isAuthed && !authError;
}
const userIsAuthed = tosPageStatus ? false : !!isAuthed && !authError;
// Only show the auth modal if:
// 1. User is not authenticated
// 2. We're not currently on the TOS page
// 3. We're in SaaS mode
// 4. We're not on the OAuth callback page
const isOAuthCallbackPage = pathname.includes("/oauth/keycloak/callback");
const renderAuthModal =
!isFetchingAuth &&
!userIsAuthed &&
!tosPageStatus &&
!isOAuthCallbackPage &&
config.data?.APP_MODE === "saas";
return (
+3 -3
View File
@@ -58,7 +58,7 @@ function SettingsScreen() {
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-9 border-b border-tertiary"
className="flex items-end gap-6 px-9 border-b border-tertiary"
>
{navItems.map(({ to, text }) => (
<NavLink
@@ -67,12 +67,12 @@ function SettingsScreen() {
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
"border-b-2 border-transparent py-2.5 px-4 min-w-[40px] flex items-center justify-center",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
<span className="text-[#F9FBFE] text-sm">{text}</span>
</NavLink>
))}
</nav>
+16
View File
@@ -152,6 +152,22 @@ export function handleAssistantMessage(message: Record<string, unknown>) {
handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else if (message.error) {
// Handle error messages from the server
const errorMessage =
typeof message.message === "string"
? message.message
: String(message.message || "Unknown error");
trackError({
message: errorMessage,
source: "websocket",
metadata: { raw_message: message },
});
store.dispatch(
addErrorMessage({
message: errorMessage,
}),
);
} else {
const errorMsg = "Unknown message type received";
trackError({
-4
View File
@@ -15,10 +15,6 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
PROVIDER_TOKENS: {
github: { token: "" },
gitlab: { token: "" },
},
IS_NEW_USER: true,
};
-4
View File
@@ -22,7 +22,6 @@ export type Settings = {
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
PROVIDER_TOKENS: Record<Provider, ProviderToken>;
IS_NEW_USER?: boolean;
};
@@ -39,17 +38,14 @@ export type ApiSettings = {
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens: Record<Provider, ProviderToken>;
provider_tokens_set: Partial<Record<Provider, string | null>>;
};
export type PostSettings = Settings & {
provider_tokens: Record<Provider, ProviderToken>;
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
};
export type PostApiSettings = ApiSettings & {
provider_tokens: Record<Provider, ProviderToken>;
user_consents_to_analytics: boolean | null;
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Utility functions for authentication
*/
/**
* Creates a logout handler function
* @param appMode The current app mode
* @returns A function that handles logout and browser refresh
*/
export const createLogoutHandler =
(appMode: string | undefined) => async (): Promise<void> => {
if (appMode === "saas") {
try {
const baseURL = `${window.location.protocol}//${
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
}`;
await fetch(`${baseURL}/api/logout`, {
method: "POST",
credentials: "include",
});
} catch (error) {
// Error during logout is not critical as we'll refresh anyway
} finally {
window.location.reload();
}
}
};
+1 -14
View File
@@ -1,4 +1,4 @@
import { Provider, ProviderToken, Settings } from "#/types/settings";
import { Settings } from "#/types/settings";
const extractBasicFormData = (formData: FormData) => {
const provider = formData.get("llm-provider-input")?.toString();
@@ -61,18 +61,6 @@ export const extractSettings = (
ENABLE_DEFAULT_CONDENSER,
} = extractAdvancedFormData(formData);
// Extract provider tokens
const githubToken = formData.get("github-token")?.toString();
const gitlabToken = formData.get("gitlab-token")?.toString();
const providerTokens: Record<Provider, ProviderToken> = {
github: {
token: githubToken || "",
},
gitlab: {
token: gitlabToken || "",
},
};
return {
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY_SET: !!LLM_API_KEY,
@@ -82,7 +70,6 @@ export const extractSettings = (
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
PROVIDER_TOKENS: providerTokens,
llm_api_key: LLM_API_KEY,
};
};
@@ -1,8 +1,12 @@
import copy
import os
from collections import deque
from typing import TYPE_CHECKING
from litellm import ChatCompletionToolParam
if TYPE_CHECKING:
from litellm import ChatCompletionToolParam
from openhands.events.action import Action
from openhands.llm.llm import ModelResponse
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
@@ -20,7 +24,7 @@ from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.events.action import Action, AgentFinishAction, MessageAction
from openhands.events.action import AgentFinishAction, MessageAction
from openhands.events.event import Event
from openhands.llm.llm import LLM
from openhands.memory.condenser import Condenser
@@ -75,23 +79,26 @@ class CodeActAgent(Agent):
- config (AgentConfig): The configuration for this agent
"""
super().__init__(llm, config)
self.pending_actions: deque[Action] = deque()
self.pending_actions: deque['Action'] = deque()
self.reset()
self.tools = self._get_tools()
self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
# Create a ConversationMemory instance
self.conversation_memory = ConversationMemory(self.config, self.prompt_manager)
self.condenser = Condenser.from_config(self.config.condenser)
logger.debug(f'Using condenser: {type(self.condenser)}')
self.response_to_actions_fn = codeact_function_calling.response_to_actions
@property
def prompt_manager(self) -> PromptManager:
if self._prompt_manager is None:
self._prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
def _get_tools(self) -> list[ChatCompletionToolParam]:
return self._prompt_manager
def _get_tools(self) -> list['ChatCompletionToolParam']:
# For these models, we use short tool descriptions ( < 1024 tokens)
# to avoid hitting the OpenAI token limit for tool descriptions.
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
@@ -130,7 +137,7 @@ class CodeActAgent(Agent):
super().reset()
self.pending_actions.clear()
def step(self, state: State) -> Action:
def step(self, state: State) -> 'Action':
"""Performs one step using the CodeAct Agent.
This includes gathering info on previous steps and prompting the model to make a command to execute.
@@ -198,9 +205,7 @@ class CodeActAgent(Agent):
params['extra_body'] = {'metadata': state.to_llm_metadata(agent_name=self.name)}
response = self.llm.completion(**params)
logger.debug(f'Response from LLM: {response}')
actions = self.response_to_actions_fn(
response, mcp_tool_names=list(self.mcp_tools.keys())
)
actions = self.response_to_actions(response)
logger.debug(f'Actions after response_to_actions: {actions}')
for action in actions:
self.pending_actions.append(action)
@@ -274,3 +279,8 @@ class CodeActAgent(Agent):
self.conversation_memory.apply_prompt_caching(messages)
return messages
def response_to_actions(self, response: 'ModelResponse') -> list['Action']:
return codeact_function_calling.response_to_actions(
response, mcp_tool_names=list(self.mcp_tools.keys())
)
@@ -76,7 +76,7 @@ def response_to_actions(
try:
arguments = json.loads(tool_call.function.arguments)
except json.decoder.JSONDecodeError as e:
raise RuntimeError(
raise FunctionCallValidationError(
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e
@@ -127,7 +127,7 @@ def response_to_actions(
try:
arguments = json.loads(tool_call.function.arguments)
except json.decoder.JSONDecodeError as e:
raise RuntimeError(
raise FunctionCallValidationError(
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e
@@ -4,6 +4,13 @@ ReadOnlyAgent - A specialized version of CodeActAgent that only uses read-only t
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from litellm import ChatCompletionToolParam
from openhands.events.action import Action
from openhands.llm.llm import ModelResponse
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.agenthub.readonly_agent import (
function_calling as readonly_function_calling,
@@ -41,24 +48,27 @@ class ReadOnlyAgent(CodeActAgent):
- llm (LLM): The llm to be used by this agent
- config (AgentConfig): The configuration for this agent
"""
# Initialize the CodeActAgent class but we'll override some of its behavior
# Initialize the CodeActAgent class; some of it is overridden with class methods
super().__init__(llm, config)
# Override the tools to only include read-only tools
# Get the read-only tools from our own function_calling module
self.tools = readonly_function_calling.get_tools()
# Set up our own prompt manager
self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
self.response_to_actions_fn = readonly_function_calling.response_to_actions
logger.debug(
f"TOOLS loaded for ReadOnlyAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}"
)
@property
def prompt_manager(self) -> PromptManager:
# Set up our own prompt manager
if self._prompt_manager is None:
self._prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
return self._prompt_manager
def _get_tools(self) -> list['ChatCompletionToolParam']:
# Override the tools to only include read-only tools
# Get the read-only tools from our own function_calling module
return readonly_function_calling.get_tools()
def set_mcp_tools(self, mcp_tools: list[dict]) -> None:
"""Sets the list of MCP tools for the agent.
@@ -68,3 +78,8 @@ class ReadOnlyAgent(CodeActAgent):
logger.warning(
'ReadOnlyAgent does not support MCP tools. MCP tools will be ignored by the agent.'
)
def response_to_actions(self, response: 'ModelResponse') -> list['Action']:
return readonly_function_calling.response_to_actions(
response, mcp_tool_names=list(self.mcp_tools.keys())
)
@@ -5,12 +5,12 @@ from prompt_toolkit import print_formatted_text
from prompt_toolkit.shortcuts import clear, print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands.core.cli_settings import (
from openhands.cli.settings import (
display_settings,
modify_llm_settings_advanced,
modify_llm_settings_basic,
)
from openhands.core.cli_tui import (
from openhands.cli.tui import (
COLOR_GREY,
UsageMetrics,
cli_confirm,
@@ -18,7 +18,7 @@ from openhands.core.cli_tui import (
display_shutdown_message,
display_status,
)
from openhands.core.cli_utils import (
from openhands.cli.utils import (
add_local_config_trusted_dir,
get_local_config_trusted_dirs,
read_file,
+34 -32
View File
@@ -6,13 +6,11 @@ from uuid import uuid4
from prompt_toolkit.shortcuts import clear
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.core.cli_commands import (
from openhands.cli.commands import (
check_folder_security_agreement,
handle_commands,
)
from openhands.core.cli_tui import (
from openhands.cli.tui import (
UsageMetrics,
display_agent_running_message,
display_banner,
@@ -25,9 +23,11 @@ from openhands.core.cli_tui import (
read_confirmation_input,
read_prompt_input,
)
from openhands.core.cli_utils import (
from openhands.cli.utils import (
update_usage_metrics,
)
from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.core.config import (
AppConfig,
parse_arguments,
@@ -101,7 +101,7 @@ async def run_session(
sid = str(uuid4())
is_loaded = asyncio.Event()
is_paused = asyncio.Event()
is_paused = asyncio.Event() # Event to track agent pause requests
# Show runtime initialization message
display_runtime_initialization_message(config.runtime)
@@ -157,20 +157,15 @@ async def run_session(
display_event(event, config)
update_usage_metrics(event, usage_metrics)
# Pause the agent if the pause event is set (if Ctrl-P is pressed)
if is_paused.is_set():
event_stream.add_event(
ChangeAgentStateAction(AgentState.PAUSED),
EventSource.USER,
)
is_paused.clear()
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
AgentState.PAUSED,
]:
# If the agent is paused, do not prompt for input as it's already handled by PAUSED state change
if is_paused.is_set():
return
# Reload microagents after initialization of repo.md
if reload_microagents:
microagents: list[BaseMicroagent] = (
@@ -181,25 +176,32 @@ async def run_session(
await prompt_for_next_task(event.agent_state)
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
# Only display the confirmation prompt if the agent is not paused
if not is_paused.is_set():
user_confirmed = await read_confirmation_input()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
# If the agent is paused, do not prompt for confirmation
# The confirmation step will re-run after the agent has been resumed
if is_paused.is_set():
return
user_confirmed = await read_confirmation_input()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
if event.agent_state == AgentState.PAUSED:
is_paused.clear() # Revert the event state before prompting for user input
await prompt_for_next_task(event.agent_state)
if event.agent_state == AgentState.RUNNING:
# Enable pause/resume functionality only if the confirmation mode is disabled
if not config.security.confirmation_mode:
display_agent_running_message()
loop.create_task(process_agent_pause(is_paused))
display_agent_running_message()
loop.create_task(
process_agent_pause(is_paused, event_stream)
) # Create a task to track agent pause requests from the user
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
@@ -5,19 +5,19 @@ from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from pydantic import SecretStr
from openhands.controller.agent import Agent
from openhands.core.cli_tui import (
from openhands.cli.tui import (
COLOR_GREY,
UserCancelledError,
cli_confirm,
kb_cancel,
)
from openhands.core.cli_utils import (
from openhands.cli.utils import (
VERIFIED_ANTHROPIC_MODELS,
VERIFIED_OPENAI_MODELS,
VERIFIED_PROVIDERS,
organize_models_and_providers,
)
from openhands.controller.agent import Agent
from openhands.core.config import AppConfig
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import OH_DEFAULT_AGENT
@@ -25,10 +25,11 @@ from prompt_toolkit.widgets import Frame, TextArea
from openhands import __version__
from openhands.core.config import AppConfig
from openhands.core.schema import AgentState
from openhands.events import EventSource
from openhands.events import EventSource, EventStream
from openhands.events.action import (
Action,
ActionConfirmationStatus,
ChangeAgentStateAction,
CmdRunAction,
FileEditAction,
MessageAction,
@@ -60,7 +61,7 @@ COMMANDS = {
'/status': 'Display session details and usage metrics',
'/new': 'Create a new session',
'/settings': 'Display and modify current settings',
'/resume': 'Resume the agent',
'/resume': 'Resume the agent when paused',
}
@@ -396,7 +397,7 @@ def display_status(usage_metrics: UsageMetrics, session_id: str):
def display_agent_running_message():
print_formatted_text('')
print_formatted_text(
HTML('<gold>Agent running...</gold> <grey>(Ctrl-P to pause)</grey>')
HTML('<gold>Agent running...</gold> <grey>(Press Ctrl-P to pause)</grey>')
)
@@ -405,7 +406,7 @@ def display_agent_paused_message(agent_state: str):
return
print_formatted_text('')
print_formatted_text(
HTML('<gold>Agent paused</gold> <grey>(type /resume to resume)</grey>')
HTML('<gold>Agent paused...</gold> <grey>(Enter /resume to continue)</grey>')
)
@@ -430,7 +431,7 @@ class CommandCompleter(Completer):
command,
start_position=-len(text),
display_meta=description,
style='bg:ansidarkgray fg:ansiwhite',
style='bg:ansidarkgray fg:gold',
)
@@ -488,7 +489,7 @@ async def read_confirmation_input() -> bool:
return False
async def process_agent_pause(done: asyncio.Event) -> None:
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
input = create_input()
def keys_ready():
@@ -496,6 +497,10 @@ async def process_agent_pause(done: asyncio.Event) -> None:
if key_press.key == Keys.ControlP:
print_formatted_text('')
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
event_stream.add_event(
ChangeAgentStateAction(AgentState.PAUSED),
EventSource.USER,
)
done.set()
with input.raw_mode():
@@ -3,7 +3,7 @@ from typing import Dict, List
import toml
from openhands.core.cli_tui import (
from openhands.cli.tui import (
UsageMetrics,
)
from openhands.events.event import Event
+8 -4
View File
@@ -8,6 +8,7 @@ if TYPE_CHECKING:
from openhands.core.config import AgentConfig
from openhands.events.action import Action
from openhands.events.action.message import SystemMessageAction
from openhands.utils.prompt import PromptManager
from litellm import ChatCompletionToolParam
from openhands.core.exceptions import (
@@ -19,9 +20,6 @@ from openhands.events.event import EventSource
from openhands.llm.llm import LLM
from openhands.runtime.plugins import PluginRequirement
if TYPE_CHECKING:
from openhands.utils.prompt import PromptManager
class Agent(ABC):
DEPRECATED = False
@@ -43,10 +41,16 @@ class Agent(ABC):
self.llm = llm
self.config = config
self._complete = False
self.prompt_manager: 'PromptManager' | None = None
self._prompt_manager: 'PromptManager' | None = None
self.mcp_tools: dict[str, ChatCompletionToolParam] = {}
self.tools: list = []
@property
def prompt_manager(self) -> 'PromptManager':
if self._prompt_manager is None:
raise ValueError(f'Prompt manager not initialized for agent {self.name}')
return self._prompt_manager
def get_system_message(self) -> 'SystemMessageAction | None':
"""
Returns a SystemMessageAction containing the system message and tools.
+13 -8
View File
@@ -741,10 +741,6 @@ class AgentController:
content = (
f'{self.delegate.agent.name} finishes task with {formatted_output}'
)
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
self.event_stream.add_event(obs, EventSource.AGENT)
else:
# delegate state is ERROR
# emit AgentDelegateObservation with error content
@@ -755,13 +751,22 @@ class AgentController:
f'{self.delegate.agent.name} encountered an error during execution.'
)
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
self.event_stream.add_event(obs, EventSource.AGENT)
content = f'Delegated agent finished with result:\n\n{content}'
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
# associate the delegate action with the initiating tool call
for event in reversed(self.state.history):
if isinstance(event, AgentDelegateAction):
delegate_action = event
obs.tool_call_metadata = delegate_action.tool_call_metadata
break
self.event_stream.add_event(obs, EventSource.AGENT)
# unset delegate so parent can resume normal handling
self.delegate = None
self.delegateAction = None
async def _step(self) -> None:
"""Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget."""
+3
View File
@@ -39,6 +39,8 @@ class SandboxConfig(BaseModel):
docker_runtime_kwargs: Additional keyword arguments to pass to the Docker runtime when running containers.
This should be a JSON string that will be parsed into a dictionary.
trusted_dirs: List of directories that can be trusted to run the OpenHands CLI.
vscode_port: The port to use for VSCode. If None, a random port will be chosen.
This is useful when deploying OpenHands in a remote machine where you need to expose a specific port.
"""
remote_runtime_api_url: str | None = Field(default='http://localhost:8000')
@@ -77,6 +79,7 @@ class SandboxConfig(BaseModel):
docker_runtime_kwargs: dict | None = Field(default=None)
selected_repo: str | None = Field(default=None)
trusted_dirs: list[str] = Field(default_factory=list)
vscode_port: int | None = Field(default=None)
model_config = {'extra': 'forbid'}
+17 -15
View File
@@ -15,7 +15,7 @@ from openhands.core.config import (
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.event import Event
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.llm.llm import LLM
from openhands.memory.memory import Memory
from openhands.microagent.microagent import BaseMicroagent
@@ -23,6 +23,7 @@ from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
from openhands.storage import get_file_store
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
@@ -85,40 +86,41 @@ def create_runtime(
def initialize_repository_for_runtime(
runtime: Runtime,
selected_repository: str | None = None,
github_token: SecretStr | None = None,
runtime: Runtime, selected_repository: str | None = None
) -> str | None:
"""Initialize the repository for the runtime.
Args:
runtime: The runtime to initialize the repository for.
selected_repository: (optional) The GitHub repository to use.
github_token: (optional) The GitHub token to use.
Returns:
The repository directory path if a repository was cloned, None otherwise.
"""
# clone selected repository if provided
if github_token is None and 'GITHUB_TOKEN' in os.environ:
provider_tokens = {}
if 'GITHUB_TOKEN' in os.environ:
github_token = SecretStr(os.environ['GITHUB_TOKEN'])
provider_tokens[ProviderType.GITHUB] = ProviderToken(
token=SecretStr(github_token)
)
if 'GITLAB_TOKEN' in os.environ:
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
provider_tokens[ProviderType.GITLAB] = ProviderToken(
token=SecretStr(gitlab_token)
)
secret_store = (
SecretStore(
provider_tokens={
ProviderType.GITHUB: ProviderToken(token=SecretStr(github_token))
}
)
if github_token
else None
UserSecrets(provider_tokens=provider_tokens) if provider_tokens else None
)
provider_tokens = secret_store.provider_tokens if secret_store else None
immutable_provider_tokens = secret_store.provider_tokens if secret_store else None
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = call_async_from_sync(
runtime.clone_or_init_repo,
GENERAL_TIMEOUT,
provider_tokens,
immutable_provider_tokens,
selected_repository,
None,
)
+44 -49
View File
@@ -19,6 +19,9 @@ from openhands.integrations.service_types import (
)
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
from openhands.integrations.github.queries import suggested_task_pr_graphql_query, suggested_task_issue_graphql_query
from datetime import datetime
from openhands.core.logger import openhands_logger as logger
class GitHubService(BaseGitService, GitService):
@@ -44,6 +47,9 @@ class GitHubService(BaseGitService, GitService):
if base_domain:
self.BASE_URL = f'https://{base_domain}/api/v3'
self.external_auth_id = external_auth_id
self.external_auth_token = external_auth_token
@property
def provider(self) -> str:
return ProviderType.GITHUB.value
@@ -284,60 +290,21 @@ class GitHubService(BaseGitService, GitService):
Returns:
- PRs authored by the user.
- Issues assigned to the user.
Note: Queries are split to avoid timeout issues.
"""
# Get user info to use in queries
user = await self.get_user()
login = user.login
query = """
query GetUserTasks($login: String!) {
user(login: $login) {
pullRequests(first: 100, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
mergeable
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
}
}
}
}
reviews(first: 100, states: [CHANGES_REQUESTED, COMMENTED]) {
nodes {
state
}
}
}
}
issues(first: 100, states: [OPEN], filterBy: {assignee: $login}, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
}
}
}
}
"""
tasks: list[SuggestedTask] = []
variables = {'login': login}
try:
response = await self.execute_graphql_query(query, variables)
data = response['data']['user']
tasks: list[SuggestedTask] = []
pr_response = await self.execute_graphql_query(suggested_task_pr_graphql_query, variables)
pr_data = pr_response['data']['user']
# Process pull requests
for pr in data['pullRequests']['nodes']:
for pr in pr_data['pullRequests']['nodes']:
repo_name = pr['repository']['nameWithOwner']
# Start with default task type
@@ -373,8 +340,18 @@ class GitHubService(BaseGitService, GitService):
)
)
except Exception as e:
logger.info(f"Error fetching suggested task for PRs: {e}",
extra={'signal': 'github_suggested_tasks', 'user_id': self.external_auth_id})
try:
# Execute issue query
issue_response = await self.execute_graphql_query(suggested_task_issue_graphql_query, variables)
issue_data = issue_response['data']['user']
# Process issues
for issue in data['issues']['nodes']:
for issue in issue_data['issues']['nodes']:
repo_name = issue['repository']['nameWithOwner']
tasks.append(
SuggestedTask(
@@ -387,8 +364,26 @@ class GitHubService(BaseGitService, GitService):
)
return tasks
except Exception:
return []
except Exception as e:
logger.info(f"Error fetching suggested task for issues: {e}",
extra={'signal': 'github_suggested_tasks', 'user_id': self.external_auth_id})
return tasks
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
url = f'{self.BASE_URL}/repos/{repository}'
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=not repo.get('private', True),
)
github_service_cls = os.environ.get(
+47
View File
@@ -0,0 +1,47 @@
suggested_task_pr_graphql_query = """
query GetUserPRs($login: String!) {
user(login: $login) {
pullRequests(first: 50, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
mergeable
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
}
}
}
}
reviews(first: 50, states: [CHANGES_REQUESTED, COMMENTED]) {
nodes {
state
}
}
}
}
}
}
"""
suggested_task_issue_graphql_query = """
query GetUserIssues($login: String!) {
user(login: $login) {
issues(first: 50, states: [OPEN], filterBy: {assignee: $login}, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
}
}
}
}
"""
@@ -382,6 +382,22 @@ class GitLabService(BaseGitService, GitService):
except Exception:
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
)
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',
+19 -111
View File
@@ -7,12 +7,8 @@ from pydantic import (
BaseModel,
Field,
SecretStr,
SerializationInfo,
WithJsonSchema,
field_serializer,
model_validator,
)
from pydantic.json import pydantic_encoder
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.action import Action
@@ -66,113 +62,6 @@ CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Annotated[
]
class SecretStore(BaseModel):
provider_tokens: PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Field(
default_factory=lambda: MappingProxyType({})
)
custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Field(
default_factory=lambda: MappingProxyType({}),
)
model_config = {
'frozen': True,
'validate_assignment': True,
'arbitrary_types_allowed': True,
}
@field_serializer('provider_tokens')
def provider_tokens_serializer(
self, provider_tokens: PROVIDER_TOKEN_TYPE, info: SerializationInfo
) -> dict[str, dict[str, str | Any]]:
tokens = {}
expose_secrets = info.context and info.context.get('expose_secrets', False)
for token_type, provider_token in provider_tokens.items():
if not provider_token or not provider_token.token:
continue
token_type_str = (
token_type.value
if isinstance(token_type, ProviderType)
else str(token_type)
)
tokens[token_type_str] = {
'token': provider_token.token.get_secret_value()
if expose_secrets
else pydantic_encoder(provider_token.token),
'user_id': provider_token.user_id,
}
return tokens
@field_serializer('custom_secrets')
def custom_secrets_serializer(
self, custom_secrets: CUSTOM_SECRETS_TYPE, info: SerializationInfo
):
secrets = {}
expose_secrets = info.context and info.context.get('expose_secrets', False)
if custom_secrets:
for secret_name, secret_key in custom_secrets.items():
secrets[secret_name] = (
secret_key.get_secret_value()
if expose_secrets
else pydantic_encoder(secret_key)
)
return secrets
@model_validator(mode='before')
@classmethod
def convert_dict_to_mappingproxy(
cls, data: dict[str, dict[str, Any] | MappingProxyType] | PROVIDER_TOKEN_TYPE
) -> dict[str, MappingProxyType | None]:
"""Custom deserializer to convert dictionary into MappingProxyType"""
if not isinstance(data, dict):
raise ValueError('SecretStore must be initialized with a dictionary')
new_data: dict[str, MappingProxyType | None] = {}
if 'provider_tokens' in data:
tokens = data['provider_tokens']
if isinstance(
tokens, dict
): # Ensure conversion happens only for dict inputs
converted_tokens = {}
for key, value in tokens.items():
try:
provider_type = (
ProviderType(key) if isinstance(key, str) else key
)
converted_tokens[provider_type] = ProviderToken.from_value(
value
)
except ValueError:
# Skip invalid provider types or tokens
continue
# Convert to MappingProxyType
new_data['provider_tokens'] = MappingProxyType(converted_tokens)
elif isinstance(tokens, MappingProxyType):
new_data['provider_tokens'] = tokens
if 'custom_secrets' in data:
secrets = data['custom_secrets']
if isinstance(secrets, dict):
converted_secrets = {}
for key, value in secrets.items():
if isinstance(value, str):
converted_secrets[key] = SecretStr(value)
elif isinstance(value, SecretStr):
converted_secrets[key] = value
new_data['custom_secrets'] = MappingProxyType(converted_secrets)
elif isinstance(secrets, MappingProxyType):
new_data['custom_secrets'] = secrets
return new_data
class ProviderHandler:
def __init__(
self,
@@ -397,3 +286,22 @@ class ProviderHandler:
Map ProviderType value to the environment variable name in the runtime
"""
return f'{provider.value}_token'.lower()
async def verify_repo_provider(
self, repository: str, specified_provider: ProviderType | None = None
):
if specified_provider:
try:
service = self._get_service(specified_provider)
return await service.get_repository_details_from_repo_name(repository)
except Exception:
pass
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
return await service.get_repository_details_from_repo_name(repository)
except Exception:
pass
raise AuthenticationError(f'Unable to access repo {repository}')
+6 -1
View File
@@ -164,7 +164,7 @@ class BaseGitService(ABC):
def handle_http_error(self, e: HTTPError) -> UnknownException:
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException('Unknown error')
return UnknownException(f'HTTP error {type(e).__name__}')
class GitService(Protocol):
@@ -206,3 +206,8 @@ class GitService(Protocol):
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
"""Gets all repository details from repository name"""
@@ -3,4 +3,4 @@ Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retriev
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then use the {{ apiName }} to look at the {{ ciSystem }} that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the {{ ciProvider }} {{ ciSystem.lower() }} have run again.
If they are still failing, repeat the process.
If they are still failing, repeat the process.
@@ -1,4 +1,4 @@
You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }}. You need to fix the merge conflicts.
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.
@@ -1,4 +1,4 @@
You are working on Issue #{{ issue_number }} in repository {{ repo }}. Your goal is to fix the issue.
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the issue details and any comments on the issue.
Then check out a new branch and investigate what changes will need to be made.
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.
@@ -2,4 +2,4 @@ You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then use the {{ apiName }} to retrieve all the feedback on the {{ requestTypeShort }} so far.
If anything hasn't been addressed, address it and commit your changes back to the same branch.
If anything hasn't been addressed, address it and commit your changes back to the same branch.
+1 -1
View File
@@ -412,7 +412,7 @@ class ConversationMemory:
logger.debug('Vision disabled for browsing, showing text')
elif isinstance(obs, AgentDelegateObservation):
text = truncate_content(
obs.outputs['content'] if 'content' in obs.outputs else '',
obs.outputs.get('content', obs.content),
max_message_chars,
)
message = Message(role='user', content=[TextContent(text=text)])
+30 -23
View File
@@ -47,7 +47,7 @@ from openhands.integrations.provider import (
ProviderHandler,
ProviderType,
)
from openhands.integrations.service_types import Repository
from openhands.integrations.service_types import AuthenticationError
from openhands.microagent import (
BaseMicroagent,
load_microagents_from_dir,
@@ -311,10 +311,23 @@ class Runtime(FileEditRuntimeMixin):
async def clone_or_init_repo(
self,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
selected_repository: str | Repository | None,
selected_repository: str | None,
selected_branch: str | None,
repository_provider: ProviderType = ProviderType.GITHUB,
) -> str:
repository = None
if selected_repository: # Determine provider from repo name
try:
provider_handler = ProviderHandler(
git_provider_tokens or MappingProxyType({})
)
repository = await provider_handler.verify_repo_provider(
selected_repository
)
except AuthenticationError:
raise RuntimeError(
'Git provider authentication issue when cloning repo'
)
if not selected_repository:
# In SaaS mode (indicated by user_id being set), always run git init
# In OSS mode, only run git init if workspace_base is not set
@@ -332,36 +345,30 @@ class Runtime(FileEditRuntimeMixin):
)
return ''
# This satisfies mypy because param is optional, but `verify_repo_provider` guarentees this gets populated
if not repository:
return ''
provider = repository.git_provider
provider_domains = {
ProviderType.GITHUB: 'github.com',
ProviderType.GITLAB: 'gitlab.com',
}
chosen_provider = (
repository_provider
if isinstance(selected_repository, str)
else selected_repository.git_provider
)
domain = provider_domains[chosen_provider]
repository = (
selected_repository
if isinstance(selected_repository, str)
else selected_repository.full_name
)
domain = provider_domains[provider]
# Try to use token if available, otherwise use public URL
if git_provider_tokens and chosen_provider in git_provider_tokens:
git_token = git_provider_tokens[chosen_provider].token
if git_provider_tokens and provider in git_provider_tokens:
git_token = git_provider_tokens[provider].token
if git_token:
if chosen_provider == ProviderType.GITLAB:
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
if provider == ProviderType.GITLAB:
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
else:
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
else:
remote_repo_url = f'https://{domain}/{repository}.git'
remote_repo_url = f'https://{domain}/{selected_repository}.git'
else:
remote_repo_url = f'https://{domain}/{repository}.git'
remote_repo_url = f'https://{domain}/{selected_repository}.git'
if not remote_repo_url:
raise ValueError('Missing either Git token or valid repository')
@@ -371,7 +378,7 @@ class Runtime(FileEditRuntimeMixin):
'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
)
dir_name = repository.split('/')[-1]
dir_name = selected_repository.split('/')[-1]
# Generate a random branch name to avoid conflicts
random_str = ''.join(
@@ -1,4 +1,3 @@
import asyncio
import os
import tempfile
import threading
@@ -46,6 +45,7 @@ from openhands.runtime.utils.request import send_request
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
def _is_retryable_error(exception):
return isinstance(
exception, (httpx.RemoteProtocolError, httpcore.RemoteProtocolError)
@@ -358,26 +358,27 @@ class ActionExecutionClient(Runtime):
async def call_tool_mcp(self, action: MCPAction) -> Observation:
# Import here to avoid circular imports
from openhands.mcp.utils import create_mcp_clients, call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import create_mcp_clients
# Get the updated MCP config
updated_mcp_config = self.get_updated_mcp_config()
self.log(
'debug',
f'Creating MCP clients with servers: {updated_mcp_config.sse_servers}',
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:
@@ -212,7 +212,11 @@ class DockerRuntime(ActionExecutionClient):
self.send_status_message('STATUS$PREPARING_CONTAINER')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
# Use the configured vscode_port if provided, otherwise find an available port
self._vscode_port = (
self.config.sandbox.vscode_port
or self._find_available_port(VSCODE_PORT_RANGE)
)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
+2
View File
@@ -18,6 +18,7 @@ from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
@@ -50,5 +51,6 @@ app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(secrets_router)
app.include_router(git_api_router)
app.include_router(trajectory_router)
+3
View File
@@ -15,6 +15,9 @@ class ServerConfig(ServerConfigInterface):
settings_store_class: str = (
'openhands.storage.settings.file_settings_store.FileSettingsStore'
)
secret_store_class: str = (
'openhands.storage.secrets.file_secrets_store.FileSecretsStore'
)
conversation_store_class: str = (
'openhands.storage.conversation.file_conversation_store.FileConversationStore'
)
@@ -10,8 +10,8 @@ from openhands.events.event_store import EventStore
from openhands.server.config.server_config import ServerConfig
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.conversation import Conversation
from openhands.storage.data_models.settings import Settings
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
@@ -18,9 +18,9 @@ from openhands.server.monitoring import MonitoringListener
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.storage.data_models.settings import Settings
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync, wait_all
from openhands.utils.import_utils import get_impl
+43 -13
View File
@@ -12,8 +12,13 @@ from openhands.events.event import EventSource
from openhands.events.stream import EventStream
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import (
AuthenticationError,
ProviderType,
SuggestedTask,
)
from openhands.integrations.service_types import Repository, SuggestedTask
from openhands.runtime import get_runtime_cls
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
@@ -29,9 +34,11 @@ from openhands.server.shared import (
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth import (
get_auth_type,
get_provider_tokens,
get_user_id,
)
from openhands.server.user_auth.user_auth import AuthType
from openhands.server.utils import get_conversation_store
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import (
@@ -42,24 +49,26 @@ from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import generate_conversation_title
app = APIRouter(prefix='/api')
class InitSessionRequest(BaseModel):
conversation_trigger: ConversationTrigger = ConversationTrigger.GUI
selected_repository: Repository | None = None
repository: str | None = None
git_provider: ProviderType | None = None
selected_branch: str | None = None
initial_user_msg: str | None = None
image_urls: list[str] | None = None
replay_json: str | None = None
suggested_task: SuggestedTask | None = None
model_config = {'extra': 'forbid'}
async def _create_new_conversation(
user_id: str | None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
selected_repository: Repository | None,
selected_repository: str | None,
selected_branch: str | None,
initial_user_msg: str | None,
image_urls: list[str] | None,
@@ -67,10 +76,13 @@ async def _create_new_conversation(
conversation_trigger: ConversationTrigger = ConversationTrigger.GUI,
attach_convo_id: bool = False,
):
print("trigger", conversation_trigger)
logger.info(
'Creating conversation',
extra={'signal': 'create_conversation', 'user_id': user_id, 'trigger': conversation_trigger.value},
extra={
'signal': 'create_conversation',
'user_id': user_id,
'trigger': conversation_trigger.value,
},
)
logger.info('Loading settings')
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
@@ -122,9 +134,7 @@ async def _create_new_conversation(
title=conversation_title,
user_id=user_id,
github_user_id=None,
selected_repository=selected_repository.full_name
if selected_repository
else selected_repository,
selected_repository=selected_repository,
selected_branch=selected_branch,
)
)
@@ -161,6 +171,7 @@ async def new_conversation(
data: InitSessionRequest,
user_id: str = Depends(get_user_id),
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
auth_type: AuthType | None = Depends(get_auth_type),
):
"""Initialize a new session or join an existing one.
@@ -168,29 +179,38 @@ async def new_conversation(
using the returned conversation ID.
"""
logger.info('Initializing new conversation')
selected_repository = data.selected_repository
repository = data.repository
selected_branch = data.selected_branch
initial_user_msg = data.initial_user_msg
image_urls = data.image_urls or []
replay_json = data.replay_json
suggested_task = data.suggested_task
conversation_trigger = data.conversation_trigger
git_provider = data.git_provider
if suggested_task:
initial_user_msg = suggested_task.get_prompt_for_task()
conversation_trigger = ConversationTrigger.SUGGESTED_TASK
if auth_type == AuthType.BEARER:
conversation_trigger = ConversationTrigger.REMOTE_API_KEY
try:
if repository:
provider_handler = ProviderHandler(provider_tokens)
# Check against git_provider, otherwise check all provider apis
await provider_handler.verify_repo_provider(repository, git_provider)
# Create conversation with initial message
conversation_id = await _create_new_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
selected_repository=selected_repository,
selected_repository=repository,
selected_branch=selected_branch,
initial_user_msg=initial_user_msg,
image_urls=image_urls,
replay_json=replay_json,
conversation_trigger=conversation_trigger
conversation_trigger=conversation_trigger,
)
return JSONResponse(
@@ -216,6 +236,16 @@ async def new_conversation(
status_code=status.HTTP_400_BAD_REQUEST,
)
except AuthenticationError as e:
return JSONResponse(
content={
'status': 'error',
'message': str(e),
'msg_id': 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR',
},
status_code=status.HTTP_400_BAD_REQUEST,
)
@app.get('/conversations')
async def search_conversations(
+1 -2
View File
@@ -2,9 +2,8 @@ from typing import Any
from fastapi import APIRouter
from openhands.security.options import SecurityAnalyzers
from openhands.controller.agent import Agent
from openhands.security.options import SecurityAnalyzers
from openhands.server.shared import config, server_config
from openhands.utils.llm import get_supported_llm_models
+283
View File
@@ -0,0 +1,283 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.utils import validate_provider_token
from openhands.server.settings import (
GETCustomSecrets,
POSTCustomSecrets,
POSTProviderModel,
)
from openhands.server.user_auth import (
get_secrets_store,
get_user_secrets,
)
from openhands.storage.data_models.settings import Settings
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
app = APIRouter(prefix='/api')
# =================================================
# SECTION: Handle git provider tokens
# =================================================
async def invalidate_legacy_secrets_store(
settings: Settings, settings_store: SettingsStore, secrets_store: SecretsStore
) -> UserSecrets | None:
"""
We are moving `secrets_store` (a field from `Settings` object) to its own dedicated store
This function moves the values from Settings to UserSecrets, and deletes the values in Settings
While this function in called multiple times, the migration only ever happens once
"""
if len(settings.secrets_store.provider_tokens.items()) > 0:
user_secrets = UserSecrets(
provider_tokens=settings.secrets_store.provider_tokens
)
await secrets_store.store(user_secrets)
# Invalidate old tokens via settings store serializer
invalidated_secrets_settings = settings.model_copy(
update={'secrets_store': UserSecrets()}
)
await settings_store.store(invalidated_secrets_settings)
return user_secrets
return None
async def check_provider_tokens(provider_info: POSTProviderModel) -> str:
print(provider_info)
if provider_info.provider_tokens:
# Determine whether tokens are valid
for token_type, token_value in provider_info.provider_tokens.items():
if token_value.token:
confirmed_token_type = await validate_provider_token(token_value.token)
if not confirmed_token_type or confirmed_token_type != token_type:
return f'Invalid token. Please make sure it is a valid {token_type.value} token.'
return ''
@app.post('/add-git-providers')
async def store_provider_tokens(
provider_info: POSTProviderModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
provider_err_msg = await check_provider_tokens(provider_info)
if provider_err_msg:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': provider_err_msg},
)
try:
user_secrets = await secrets_store.load()
if not user_secrets:
user_secrets = UserSecrets()
if provider_info.provider_tokens:
existing_providers = [provider for provider in user_secrets.provider_tokens]
# Merge incoming settings store with the existing one
for provider, token_value in list(provider_info.provider_tokens.items()):
if provider in existing_providers and not token_value.token:
existing_token = user_secrets.provider_tokens.get(provider)
if existing_token and existing_token.token:
provider_info.provider_tokens[provider] = existing_token
else: # nothing passed in means keep current settings
provider_info.provider_tokens = dict(user_secrets.provider_tokens)
updated_secrets = user_secrets.model_copy(
update={'provider_tokens': provider_info.provider_tokens}
)
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Git providers stored'},
)
except Exception as e:
logger.warning(f'Something went wrong storing git providers: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing git providers'},
)
@app.post('/unset-provider-tokens', response_model=dict[str, str])
async def unset_provider_tokens(
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
user_secrets = await secrets_store.load()
if user_secrets:
updated_secrets = user_secrets.model_copy(update={'provider_tokens': {}})
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Unset Git provider tokens'},
)
except Exception as e:
logger.warning(f'Something went wrong unsetting tokens: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong unsetting tokens'},
)
# =================================================
# SECTION: Handle custom secrets
# =================================================
@app.get('/secrets', response_model=GETCustomSecrets)
async def load_custom_secrets_names(
user_secrets: UserSecrets | None = Depends(get_user_secrets),
) -> GETCustomSecrets | JSONResponse:
try:
if not user_secrets:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'User secrets not found'},
)
custom_secrets = list(user_secrets.custom_secrets.keys())
return GETCustomSecrets(custom_secrets=custom_secrets)
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
@app.post('/secrets', response_model=dict[str, str])
async def create_custom_secret(
incoming_secret: POSTCustomSecrets,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
existing_secrets = await secrets_store.load()
if existing_secrets:
custom_secrets = dict(existing_secrets.custom_secrets)
for secret_name, secret_value in incoming_secret.custom_secrets.items():
if secret_name in custom_secrets:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'message': f'Secret {secret_name} already exists'},
)
custom_secrets[secret_name] = secret_value
# Create a new UserSecrets that preserves provider tokens
updated_user_secrets = UserSecrets(
custom_secrets=custom_secrets,
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_user_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Secret created successfully'},
)
except Exception as e:
logger.warning(f'Something went wrong creating secret: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong creating secret'},
)
@app.put('/secrets/{secret_id}', response_model=dict[str, str])
async def update_custom_secret(
secret_id: str,
incoming_secret: POSTCustomSecrets,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
existing_secrets = await secrets_store.load()
if existing_secrets:
# Check if the secret to update exists
if secret_id not in existing_secrets.custom_secrets:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': f'Secret with ID {secret_id} not found'},
)
custom_secrets = dict(existing_secrets.custom_secrets)
custom_secrets.pop(secret_id)
for secret_name, secret_value in incoming_secret.custom_secrets.items():
custom_secrets[secret_name] = secret_value
# Create a new UserSecrets that preserves provider tokens
updated_secrets = UserSecrets(
custom_secrets=custom_secrets,
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Secret updated successfully'},
)
except Exception as e:
logger.warning(f'Something went wrong updating secret: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong updating secret'},
)
@app.delete('/secrets/{secret_id}')
async def delete_custom_secret(
secret_id: str,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
existing_secrets = await secrets_store.load()
if existing_secrets:
# Get existing custom secrets
custom_secrets = dict(existing_secrets.custom_secrets)
# Check if the secret to delete exists
if secret_id not in custom_secrets:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': f'Secret with ID {secret_id} not found'},
)
# Remove the secret
custom_secrets.pop(secret_id)
# Create a new UserSecrets that preserves provider tokens and remaining secrets
updated_secrets = UserSecrets(
custom_secrets=custom_secrets,
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Secret deleted successfully'},
)
except Exception as e:
logger.warning(f'Something went wrong deleting secret: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong deleting secret'},
)
+25 -209
View File
@@ -5,22 +5,19 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderType,
SecretStore,
)
from openhands.integrations.utils import validate_provider_token
from openhands.server.routes.secrets import invalidate_legacy_secrets_store
from openhands.server.settings import (
GETSettingsCustomSecrets,
GETSettingsModel,
POSTSettingsCustomSecrets,
POSTSettingsModel,
)
from openhands.server.shared import config
from openhands.storage.data_models.settings import Settings
from openhands.server.user_auth import (
get_provider_tokens,
get_user_settings,
get_secrets_store,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
app = APIRouter(prefix='/api')
@@ -29,8 +26,11 @@ app = APIRouter(prefix='/api')
@app.get('/settings', response_model=GETSettingsModel)
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings: Settings | None = Depends(get_user_settings),
settings_store: SettingsStore = Depends(get_user_settings_store),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
settings = await settings_store.load()
try:
if not settings:
return JSONResponse(
@@ -38,9 +38,18 @@ async def load_settings(
content={'error': 'Settings not found'},
)
provider_tokens_set: dict[ProviderType, str | None] = {}
if provider_tokens:
for provider_type, provider_token in provider_tokens.items():
# On initial load, user secrets may not be populated with values migrated from settings store
user_secrets = await invalidate_legacy_secrets_store(
settings, settings_store, secrets_store
)
# If invalidation is successful, then the returned user secrets holds the most recent values
git_providers = (
user_secrets.provider_tokens if user_secrets else provider_tokens
)
provider_tokens_set: dict[ProviderType, str | None] = {}
if git_providers:
for provider_type, provider_token in git_providers.items():
if provider_token.token or provider_token.user_id:
provider_tokens_set[provider_type] = None
@@ -60,140 +69,6 @@ async def load_settings(
)
@app.get('/secrets', response_model=GETSettingsCustomSecrets)
async def load_custom_secrets_names(
settings: Settings | None = Depends(get_user_settings),
) -> GETSettingsCustomSecrets | JSONResponse:
try:
if not settings:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Settings not found'},
)
custom_secrets = []
if settings.secrets_store.custom_secrets:
for secret_name, _ in settings.secrets_store.custom_secrets.items():
custom_secrets.append(secret_name)
secret_names = GETSettingsCustomSecrets(custom_secrets=custom_secrets)
return secret_names
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
@app.post('/secrets', response_model=dict[str, str])
async def add_custom_secret(
incoming_secrets: POSTSettingsCustomSecrets,
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
try:
existing_settings = await settings_store.load()
if existing_settings:
for (
secret_name,
secret_value,
) in existing_settings.secrets_store.custom_secrets.items():
if (
secret_name not in incoming_secrets.custom_secrets
): # Allow incoming values to override existing ones
incoming_secrets.custom_secrets[secret_name] = secret_value
# Create a new SecretStore that preserves provider tokens
updated_secret_store = SecretStore(
custom_secrets=incoming_secrets.custom_secrets,
provider_tokens=existing_settings.secrets_store.provider_tokens,
)
# Only update SecretStore in Settings
updated_settings = existing_settings.model_copy(
update={'secrets_store': updated_secret_store}
)
await settings_store.store(updated_settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Something went wrong storing settings: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing settings'},
)
@app.delete('/secrets/{secret_id}')
async def delete_custom_secret(
secret_id: str,
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
try:
existing_settings: Settings | None = await settings_store.load()
custom_secrets = {}
if existing_settings:
for (
secret_name,
secret_value,
) in existing_settings.secrets_store.custom_secrets.items():
if secret_name != secret_id:
custom_secrets[secret_name] = secret_value
# Create a new SecretStore that preserves provider tokens
updated_secret_store = SecretStore(
custom_secrets=custom_secrets,
provider_tokens=existing_settings.secrets_store.provider_tokens,
)
updated_settings = existing_settings.model_copy(
update={'secrets_store': updated_secret_store}
)
await settings_store.store(updated_settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Something went wrong storing settings: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing settings'},
)
@app.post('/unset-settings-tokens', response_model=dict[str, str])
async def unset_settings_tokens(
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
try:
existing_settings = await settings_store.load()
if existing_settings:
settings = existing_settings.model_copy(
update={'secrets_store': SecretStore()}
)
await settings_store.store(settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Something went wrong unsetting tokens: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong unsetting tokens'},
)
@app.post('/reset-settings', response_model=dict[str, str])
async def reset_settings() -> JSONResponse:
"""
@@ -206,52 +81,9 @@ async def reset_settings() -> JSONResponse:
)
async def check_provider_tokens(settings: POSTSettingsModel) -> str:
if settings.provider_tokens:
# Determine whether tokens are valid
for provider_type, provider_token in settings.provider_tokens.items():
if provider_token.token:
confirmed_token_type = await validate_provider_token(
provider_token.token
)
if not confirmed_token_type or confirmed_token_type != provider_type:
return f'Invalid token. Please make sure it is a valid {provider_type.value} token.'
return ''
async def store_provider_tokens(
settings: POSTSettingsModel, settings_store: SettingsStore
):
existing_settings = await settings_store.load()
if existing_settings:
if existing_settings.secrets_store:
existing_providers = [
provider
for provider in existing_settings.secrets_store.provider_tokens
]
# Merge incoming settings store with the existing one
for provider_type, provider_value in list(settings.provider_tokens.items()):
if provider_type in existing_providers and not provider_value.token:
existing_token = (
existing_settings.secrets_store.provider_tokens.get(
provider_type
)
)
if existing_token and existing_token.token:
settings.provider_tokens[provider_type] = existing_token
else: # nothing passed in means keep current settings
provider_tokens = dict(existing_settings.secrets_store.provider_tokens)
settings.provider_tokens = provider_tokens
return settings
async def store_llm_settings(
settings: POSTSettingsModel, settings_store: SettingsStore
) -> POSTSettingsModel:
settings: Settings, settings_store: SettingsStore
) -> Settings:
existing_settings = await settings_store.load()
# Convert to Settings model and merge with existing settings
@@ -269,17 +101,10 @@ async def store_llm_settings(
@app.post('/settings', response_model=dict[str, str])
async def store_settings(
settings: POSTSettingsModel,
settings: Settings,
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
# Check provider tokens are valid
provider_err_msg = await check_provider_tokens(settings)
if provider_err_msg:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': provider_err_msg},
)
try:
existing_settings = await settings_store.load()
@@ -293,8 +118,6 @@ async def store_settings(
existing_settings.user_consents_to_analytics
)
settings = await store_provider_tokens(settings, settings_store)
# Update sandbox config with new settings
if settings.remote_runtime_resource_factor is not None:
config.sandbox.remote_runtime_resource_factor = (
@@ -315,7 +138,7 @@ async def store_settings(
)
def convert_to_settings(settings_with_token_data: POSTSettingsModel) -> Settings:
def convert_to_settings(settings_with_token_data: Settings) -> Settings:
settings_data = settings_with_token_data.model_dump()
# Filter out additional fields from `SettingsWithTokenData`
@@ -328,13 +151,6 @@ def convert_to_settings(settings_with_token_data: POSTSettingsModel) -> Settings
# Convert the `llm_api_key` to a `SecretStr` instance
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
# Create a new Settings instance with empty SecretStore
# Create a new Settings instance
settings = Settings(**filtered_settings_data)
# Create new provider tokens immutably
if settings_with_token_data.provider_tokens:
settings = settings.model_copy(
update={'secrets_store': SecretStore(provider_tokens=settings_with_token_data.provider_tokens)}
)
return settings
+6 -9
View File
@@ -17,7 +17,6 @@ from openhands.events.action import ChangeAgentStateAction, MessageAction
from openhands.events.event import Event, EventSource
from openhands.events.stream import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.mcp import add_mcp_tools_to_agent
from openhands.memory.memory import Memory
from openhands.microagent.microagent import BaseMicroagent
@@ -86,7 +85,7 @@ class AgentSession:
max_budget_per_task: float | None = None,
agent_to_llm_config: dict[str, LLMConfig] | None = None,
agent_configs: dict[str, AgentConfig] | None = None,
selected_repository: Repository | None = None,
selected_repository: str | None = None,
selected_branch: str | None = None,
initial_message: MessageAction | None = None,
replay_json: str | None = None,
@@ -153,7 +152,7 @@ class AgentSession:
repo_directory = None
if self.runtime and runtime_connected and selected_repository:
repo_directory = selected_repository.full_name.split('/')[-1]
repo_directory = selected_repository.split('/')[-1]
self.memory = await self._create_memory(
selected_repository=selected_repository,
@@ -265,7 +264,7 @@ class AgentSession:
config: AppConfig,
agent: Agent,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
selected_repository: Repository | None = None,
selected_repository: str | None = None,
selected_branch: str | None = None,
) -> bool:
"""Creates a runtime instance
@@ -400,7 +399,7 @@ class AgentSession:
return controller
async def _create_memory(
self, selected_repository: Repository | None, repo_directory: str | None
self, selected_repository: str | None, repo_directory: str | None
) -> Memory:
memory = Memory(
event_stream=self.event_stream,
@@ -415,14 +414,12 @@ class AgentSession:
# loads microagents from repo/.openhands/microagents
microagents: list[BaseMicroagent] = await call_sync_from_async(
self.runtime.get_microagents_from_selected_repo,
selected_repository.full_name if selected_repository else None,
selected_repository or None,
)
memory.load_user_workspace_microagents(microagents)
if selected_repository and repo_directory:
memory.set_repository_info(
selected_repository.full_name, repo_directory
)
memory.set_repository_info(selected_repository, repo_directory)
return memory
def _maybe_restore_state(self) -> State | None:
@@ -1,7 +1,6 @@
from pydantic import Field
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import Repository
from openhands.storage.data_models.settings import Settings
@@ -11,7 +10,7 @@ class ConversationInitData(Settings):
"""
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = Field(default=None, frozen=True)
selected_repository: Repository | None = Field(default=None)
selected_repository: str | None = Field(default=None)
replay_json: str | None = Field(default=None)
selected_branch: str | None = Field(default=None)
+3 -1
View File
@@ -21,6 +21,7 @@ from openhands.events.observation import (
CmdOutputObservation,
NullObservation,
)
from openhands.events.observation.agent import RecallObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStreamSubscriber
@@ -213,7 +214,8 @@ class Session:
await self.send(event_to_dict(event))
# NOTE: ipython observations are not sent here currently
elif event.source == EventSource.ENVIRONMENT and isinstance(
event, (CmdOutputObservation, AgentStateChangedObservation)
event,
(CmdOutputObservation, AgentStateChangedObservation, RecallObservation),
):
# feedback from the environment to agent actions is understood as agent events by the UI
event_dict = event_to_dict(event)
+3 -3
View File
@@ -10,7 +10,7 @@ from openhands.integrations.service_types import ProviderType
from openhands.storage.data_models.settings import Settings
class POSTSettingsModel(Settings):
class POSTProviderModel(BaseModel):
"""
Settings for POST requests
"""
@@ -18,7 +18,7 @@ class POSTSettingsModel(Settings):
provider_tokens: dict[ProviderType, ProviderToken] = {}
class POSTSettingsCustomSecrets(BaseModel):
class POSTCustomSecrets(BaseModel):
"""
Adding new custom secret
"""
@@ -37,7 +37,7 @@ class GETSettingsModel(Settings):
llm_api_key_set: bool
class GETSettingsCustomSecrets(BaseModel):
class GETCustomSecrets(BaseModel):
"""
Custom secrets names
"""
+3
View File
@@ -11,6 +11,7 @@ from openhands.server.conversation_manager.conversation_manager import (
from openhands.server.monitoring import MonitoringListener
from openhands.storage import get_file_store
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
@@ -51,6 +52,8 @@ conversation_manager = ConversationManagerImpl.get_instance( # type: ignore
SettingsStoreImpl = get_impl(SettingsStore, server_config.settings_store_class) # type: ignore
SecretsStoreImpl = get_impl(SecretsStore, server_config.secret_store_class)
ConversationStoreImpl = get_impl(
ConversationStore, # type: ignore
server_config.conversation_store_class,
+20 -1
View File
@@ -4,7 +4,9 @@ from pydantic import SecretStr
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
from openhands.server.settings import Settings
from openhands.server.user_auth.user_auth import get_user_auth
from openhands.server.user_auth.user_auth import AuthType, get_user_auth
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
@@ -42,7 +44,24 @@ async def get_user_settings(request: Request) -> Settings | None:
return user_settings
async def get_secrets_store(request: Request) -> SecretsStore:
user_auth = await get_user_auth(request)
secrets_store = await user_auth.get_secrets_store()
return secrets_store
async def get_user_secrets(request: Request) -> UserSecrets | None:
user_auth = await get_user_auth(request)
user_secrets = await user_auth.get_user_secrets()
return user_secrets
async def get_user_settings_store(request: Request) -> SettingsStore | None:
user_auth = await get_user_auth(request)
user_settings_store = await user_auth.get_user_settings_store()
return user_settings_store
async def get_auth_type(request: Request) -> AuthType | None:
user_auth = await get_user_auth(request)
return user_auth.get_auth_type()
@@ -7,6 +7,8 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.server import shared
from openhands.server.settings import Settings
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
@@ -16,6 +18,8 @@ class DefaultUserAuth(UserAuth):
_settings: Settings | None = None
_settings_store: SettingsStore | None = None
_secrets_store: SecretsStore | None = None
_user_secrets: UserSecrets | None = None
async def get_user_id(self) -> str | None:
"""The default implementation does not support multi tenancy, so user_id is always None"""
@@ -45,9 +49,28 @@ class DefaultUserAuth(UserAuth):
self._settings = settings
return settings
async def get_secrets_store(self):
secrets_store = self._secrets_store
if secrets_store:
return secrets_store
user_id = await self.get_user_id()
secret_store = await shared.SecretsStoreImpl.get_instance(
shared.config, user_id
)
self._secrets_store = secret_store
return secret_store
async def get_user_secrets(self) -> UserSecrets | None:
user_secrets = self._user_secrets
if user_secrets:
return user_secrets
secrets_store = await self.get_secrets_store()
user_secrets = await secrets_store.load()
self._user_secrets = user_secrets
return user_secrets
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
settings = await self.get_user_settings()
secrets_store = getattr(settings, 'secrets_store', None)
secrets_store = await self.get_user_secrets()
provider_tokens = getattr(secrets_store, 'provider_tokens', None)
return provider_tokens
+19
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from fastapi import Request
from pydantic import SecretStr
@@ -8,10 +9,17 @@ from pydantic import SecretStr
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.server.settings import Settings
from openhands.server.shared import server_config
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
class AuthType(Enum):
COOKIE = 'cookie'
BEARER = 'bearer'
class UserAuth(ABC):
"""Extensible class encapsulating user Authentication"""
@@ -45,6 +53,17 @@ class UserAuth(ABC):
self._settings = settings
return settings
@abstractmethod
async def get_secrets_store(self) -> SecretsStore:
"""Get secrets store"""
@abstractmethod
async def get_user_secrets(self) -> UserSecrets | None:
"""Get the user's secrets"""
def get_auth_type(self) -> AuthType | None:
return None
@classmethod
@abstractmethod
async def get_instance(cls, request: Request) -> UserAuth:

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