mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
136 Commits
async-git-
...
gitlab-UI-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d59a819e11 | ||
|
|
2d6273a15f | ||
|
|
c1f7c400e6 | ||
|
|
0d6d91b1fd | ||
|
|
4ef479dbdc | ||
|
|
1632fe4a1f | ||
|
|
8ecd4a4b80 | ||
|
|
ec1f72da93 | ||
|
|
3b8e9bcc2b | ||
|
|
c6826711d3 | ||
|
|
466b099587 | ||
|
|
87de22bd82 | ||
|
|
1f87801a85 | ||
|
|
a35267b425 | ||
|
|
1d42e8de99 | ||
|
|
478da20c6f | ||
|
|
1d4041f1bf | ||
|
|
e6a900ded6 | ||
|
|
bb9dc8f6ca | ||
|
|
99f1bff8c4 | ||
|
|
a7d93218a2 | ||
|
|
babf9b723c | ||
|
|
c41ea766d9 | ||
|
|
10e0f806d6 | ||
|
|
ea33598a84 | ||
|
|
759d2435cf | ||
|
|
e6f9b947b6 | ||
|
|
2bcadfbbf8 | ||
|
|
7625665cda | ||
|
|
56ce1b73db | ||
|
|
4f73a19356 | ||
|
|
ec2119a32b | ||
|
|
7aa3abb86f | ||
|
|
6dc5e4deae | ||
|
|
0d51ab4f9d | ||
|
|
2e49b47ce2 | ||
|
|
9ae2396734 | ||
|
|
32065ab766 | ||
|
|
db87274c8c | ||
|
|
47e6612239 | ||
|
|
333660e6a4 | ||
|
|
bd9f262fb3 | ||
|
|
02cd6b1daf | ||
|
|
1af8994b21 | ||
|
|
e95de2abce | ||
|
|
9a57092a43 | ||
|
|
d81935867a | ||
|
|
9618366bbb | ||
|
|
44fa2bff44 | ||
|
|
dda4a83e14 | ||
|
|
18f433d20e | ||
|
|
deb1c64368 | ||
|
|
d282396ed0 | ||
|
|
4b7d47fe5d | ||
|
|
c9e2076457 | ||
|
|
23173f63d7 | ||
|
|
181a22f299 | ||
|
|
44333cce4a | ||
|
|
10f0fdf4fa | ||
|
|
622f28049a | ||
|
|
7bd2127adb | ||
|
|
b8310f05a9 | ||
|
|
06d63784e4 | ||
|
|
49fc779505 | ||
|
|
52fa4a77c4 | ||
|
|
643816b8ba | ||
|
|
dfe837870a | ||
|
|
fd562db2f7 | ||
|
|
e6e979c174 | ||
|
|
752a3ec5f5 | ||
|
|
6922f885c6 | ||
|
|
98fe41c6b2 | ||
|
|
48fb439686 | ||
|
|
10524eb79a | ||
|
|
953cf837bb | ||
|
|
70ef550401 | ||
|
|
994d21df4f | ||
|
|
7dc8fe682b | ||
|
|
5a82d646c3 | ||
|
|
4a83806ec6 | ||
|
|
e9ee0f9369 | ||
|
|
c141d2a46c | ||
|
|
a23d1cfc6a | ||
|
|
0923332930 | ||
|
|
003d8ede71 | ||
|
|
d4f6c7943a | ||
|
|
86840492ab | ||
|
|
3a3601c7b6 | ||
|
|
406008ecb0 | ||
|
|
72b240906b | ||
|
|
630b8af588 | ||
|
|
20a60633b2 | ||
|
|
c949e62fe9 | ||
|
|
41b18994fc | ||
|
|
368583491f | ||
|
|
600b3157b9 | ||
|
|
47109d9551 | ||
|
|
169cb80d70 | ||
|
|
3e9e538580 | ||
|
|
e68970e2e5 | ||
|
|
99351a6f58 | ||
|
|
37732752b5 | ||
|
|
cd248684f7 | ||
|
|
15c059990c | ||
|
|
9275be4182 | ||
|
|
0d116e31dc | ||
|
|
3eb4d9c36c | ||
|
|
ca8f5decee | ||
|
|
d79413bde4 | ||
|
|
9501310244 | ||
|
|
9821653aca | ||
|
|
9cb51891f1 | ||
|
|
7284a95960 | ||
|
|
55bd40cef0 | ||
|
|
e4b399449a | ||
|
|
1b0646f1ef | ||
|
|
6b5a901143 | ||
|
|
ceddc24d98 | ||
|
|
3731511ac2 | ||
|
|
2e7dba7340 | ||
|
|
4b7632012b | ||
|
|
6d7f300d1b | ||
|
|
ffc60d2bef | ||
|
|
c5c1be1e18 | ||
|
|
fdb5253a52 | ||
|
|
d409835377 | ||
|
|
714ccda10b | ||
|
|
62d4a73eba | ||
|
|
501dc1b35d | ||
|
|
37ca5c97f5 | ||
|
|
ee11aea9f0 | ||
|
|
dbd59e34dc | ||
|
|
938595cc12 | ||
|
|
56f6d1ea28 | ||
|
|
a891d13049 | ||
|
|
62f8095153 |
13
Makefile
13
Makefile
@@ -39,7 +39,6 @@ ifeq ($(INSTALL_DOCKER),)
|
||||
@$(MAKE) -s check-docker
|
||||
endif
|
||||
@$(MAKE) -s check-poetry
|
||||
@$(MAKE) -s check-tmux
|
||||
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
|
||||
|
||||
check-system:
|
||||
@@ -102,18 +101,6 @@ check-docker:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
check-tmux:
|
||||
@echo "$(YELLOW)Checking tmux installation...$(RESET)"
|
||||
@if command -v tmux > /dev/null; then \
|
||||
echo "$(BLUE)$(shell tmux -V) is already installed.$(RESET)"; \
|
||||
else \
|
||||
echo "$(YELLOW)╔════════════════════════════════════════════════════════════════════════════╗$(RESET)"; \
|
||||
echo "$(YELLOW)║ OPTIONAL: tmux is not installed. ║$(RESET)"; \
|
||||
echo "$(YELLOW)║ Some advanced terminal features may not work without tmux. ║$(RESET)"; \
|
||||
echo "$(YELLOW)║ You can install it if needed, but it's not required for development. ║$(RESET)"; \
|
||||
echo "$(YELLOW)╚════════════════════════════════════════════════════════════════════════════╝$(RESET)"; \
|
||||
fi
|
||||
|
||||
check-poetry:
|
||||
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
|
||||
@if command -v poetry > /dev/null; then \
|
||||
|
||||
@@ -61,8 +61,8 @@ RUN add-apt-repository ppa:deadsnakes/ppa \
|
||||
&& apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip \
|
||||
&& ln -s /usr/bin/python3.12 /usr/bin/python
|
||||
|
||||
# NodeJS >= 22.x
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
# NodeJS >= 18.17.1
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Poetry >= 1.8
|
||||
@@ -108,7 +108,7 @@ WORKDIR /app
|
||||
|
||||
# cache build dependencies
|
||||
RUN \
|
||||
--mount=type=bind,source=./,target=/app/,rw \
|
||||
--mount=type=bind,source=./,target=/app/ \
|
||||
<<EOF
|
||||
#!/bin/bash
|
||||
make -s clean
|
||||
|
||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@@ -3,7 +3,6 @@
|
||||
|
||||
# Production
|
||||
/build
|
||||
/static/swagger-ui
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
|
||||
@@ -36,7 +36,6 @@ const config: Config = {
|
||||
mermaid: true,
|
||||
},
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
plugins: [],
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
@@ -76,11 +75,6 @@ const config: Config = {
|
||||
position: 'left',
|
||||
label: 'User Guides',
|
||||
},
|
||||
{
|
||||
href: 'https://docs.all-hands.dev/swagger-ui/', // FIXME: this should be a relative path, but docusarus steals the click
|
||||
label: 'API',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'left',
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const swaggerUiDist = require('swagger-ui-dist');
|
||||
|
||||
/**
|
||||
* This script manually sets up Swagger UI for the Docusaurus documentation.
|
||||
*
|
||||
* Why we need this approach:
|
||||
* 1. Docusaurus doesn't have a built-in way to integrate Swagger UI
|
||||
* 2. We need to copy the necessary files from swagger-ui-dist to our static directory
|
||||
* 3. We need to create a custom index.html file that points to our OpenAPI spec
|
||||
* 4. This approach allows us to customize the Swagger UI to match our documentation style
|
||||
*/
|
||||
|
||||
// Get the absolute path to the swagger-ui-dist package
|
||||
const swaggerUiDistPath = swaggerUiDist.getAbsoluteFSPath();
|
||||
|
||||
// Create the target directory if it doesn't exist
|
||||
const targetDir = path.join(__dirname, 'static', 'swagger-ui');
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy all files from swagger-ui-dist to our target directory
|
||||
const files = fs.readdirSync(swaggerUiDistPath);
|
||||
files.forEach(file => {
|
||||
const sourcePath = path.join(swaggerUiDistPath, file);
|
||||
const targetPath = path.join(targetDir, file);
|
||||
|
||||
// Skip directories and non-essential files
|
||||
if (fs.statSync(sourcePath).isDirectory() ||
|
||||
file === 'package.json' ||
|
||||
file === 'README.md' ||
|
||||
file.endsWith('.map')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
});
|
||||
|
||||
// Create a custom index.html file that points to our OpenAPI spec
|
||||
const indexHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OpenHands API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
||||
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
// End Swagger UI call region
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, 'index.html'), indexHtml);
|
||||
|
||||
console.log('Swagger UI files generated successfully in static/swagger-ui/');
|
||||
@@ -8,22 +8,18 @@ OpenHands Cloud can be accessed at https://app.all-hands.dev/.
|
||||
|
||||
## Getting Started
|
||||
|
||||
After visiting OpenHands Cloud, you will be asked to connect with your GitHub or GitLab account:
|
||||
|
||||
1. After reading and accepting the terms of service, click `Log in with GitHub` or `Log in with GitLab`.
|
||||
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
|
||||
1. After reading and accepting the terms of service, click `Connect to GitHub`.
|
||||
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands AI`.
|
||||
- OpenHands will require some permissions from your GitHub or GitLab account. To read more about these permissions:
|
||||
- GitHub: You can click the `Learn more` link on the GitHub authorize page.
|
||||
- GitLab: You can expand each permission request on the GitLab authorize page.
|
||||
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
|
||||
you can click the `Learn more` link on the GitHub authorize page.
|
||||
|
||||
## Repository Access
|
||||
|
||||
### GitHub
|
||||
|
||||
#### Adding Repository Access
|
||||
### Adding Repository Access
|
||||
|
||||
You can grant OpenHands specific repository access:
|
||||
1. Click `Add GitHub repos` on the Home page.
|
||||
1. Click the `Select a Git project` dropdown, select `Add more repositories...`.
|
||||
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
|
||||
<details>
|
||||
<summary>Permission Details for Repository Access</summary>
|
||||
@@ -46,15 +42,11 @@ You can grant OpenHands specific repository access:
|
||||
|
||||
3. Click on `Install & Authorize`.
|
||||
|
||||
#### Modifying Repository Access
|
||||
### Modifying Repository Access
|
||||
|
||||
You can modify GitHub repository access at any time by:
|
||||
* Using the same `Add GitHub repos` workflow, or
|
||||
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git Settings` section.
|
||||
|
||||
### GitLab
|
||||
|
||||
When using your GitLab account, OpenHands will automatically have access to your repositories.
|
||||
You can modify repository access at any time by:
|
||||
* Using the same `Select a Git project > Add more repositories` workflow, or
|
||||
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `GitHub Settings` section.
|
||||
|
||||
## Conversation Persistence
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
- Displays the conversation between the user and OpenHands.
|
||||
- OpenHands explains its actions in this panel.
|
||||
|
||||
### Changes
|
||||
- Shows the file changes performed by OpenHands.
|
||||
|
||||
### Workspace
|
||||
- Browse project files and directories.
|
||||
- Use the `Open in VS Code` option to:
|
||||
@@ -23,7 +20,7 @@
|
||||
- Particularly handy when using OpenHands to perform data visualization tasks.
|
||||
|
||||
### App
|
||||
- Displays the web server when OpenHands runs an application.
|
||||
- Shows the web server when OpenHands runs an application.
|
||||
- Users can interact with the running application.
|
||||
|
||||
### Browser
|
||||
|
||||
433
docs/package-lock.json
generated
433
docs/package-lock.json
generated
@@ -24,8 +24,6 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"swagger-cli": "^4.0.4",
|
||||
"swagger-ui-dist": "^5.21.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -275,273 +273,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/openapi-schemas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz",
|
||||
"integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==",
|
||||
"deprecated": "This package has been abandoned. Please switch to using the actively maintained @redocly/cli",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^10.0.1",
|
||||
"chalk": "^4.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"yargs": "^15.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"swagger-cli": "bin/swagger-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-methods": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-parser": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz",
|
||||
"integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "11.7.2",
|
||||
"@apidevtools/openapi-schemas": "^2.1.0",
|
||||
"@apidevtools/swagger-methods": "^3.0.2",
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-draft-04": "^1.0.0",
|
||||
"call-me-maybe": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openapi-types": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
"version": "11.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
|
||||
"integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/philsturgeon"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
@@ -4104,13 +3835,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
@@ -4246,14 +3970,6 @@
|
||||
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||
@@ -5251,21 +4967,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-draft-04": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
||||
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
@@ -5848,13 +5549,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-me-maybe": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -7498,16 +7192,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
|
||||
@@ -8916,16 +8600,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
||||
@@ -13436,16 +13110,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -13754,14 +13427,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
@@ -14144,9 +13809,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
||||
"version": "8.4.38",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
||||
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -14161,11 +13826,10 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -15703,15 +15367,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pupa": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz",
|
||||
@@ -16550,16 +16205,6 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -16577,13 +16222,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -17064,13 +16702,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -17349,10 +16980,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
||||
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -17717,32 +17347,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
|
||||
},
|
||||
"node_modules/swagger-cli": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/swagger-cli/-/swagger-cli-4.0.4.tgz",
|
||||
"integrity": "sha512-Cp8YYuLny3RJFQ4CvOBTaqmOOgYsem52dPx1xM5S4EUWFblIh2Q8atppMZvXKUr1e9xH5RwipYpmdUzdPcxWcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-cli": "4.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"swagger-cli": "swagger-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz",
|
||||
"integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
@@ -18345,6 +17949,14 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/url-loader": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
|
||||
@@ -18998,13 +18610,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
|
||||
|
||||
@@ -4,18 +4,16 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "node generate-swagger-ui.js && docusaurus start",
|
||||
"build": "node generate-swagger-ui.js && docusaurus build",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc",
|
||||
"generate-swagger-ui": "node generate-swagger-ui.js"
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"// Note": "The OpenAPI spec is stored in docs/static/openapi.json so it's accessible at /openapi.json in the deployed site",
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.7.0",
|
||||
@@ -33,8 +31,6 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"swagger-cli": "^4.0.4",
|
||||
"swagger-ui-dist": "^5.21.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -51,6 +47,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,4 +268,4 @@ const sidebars: SidebarsConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
export default sidebars;
|
||||
|
||||
15
docs/static/README.md
vendored
15
docs/static/README.md
vendored
@@ -1,15 +0,0 @@
|
||||
# Static Files for OpenHands Documentation
|
||||
|
||||
This directory contains static files that are copied directly to the build output of the Docusaurus documentation.
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
The `openapi.json` file in this directory is the OpenAPI specification for the OpenHands API. It is copied to the build output and is accessible at `/openapi.json` in the deployed site.
|
||||
|
||||
This file is used by the Swagger UI interface, which is accessible at `/swagger-ui/` in the deployed site.
|
||||
|
||||
## Why is the OpenAPI spec in the static directory?
|
||||
|
||||
The OpenAPI specification is placed in the static directory so that it's accessible at a predictable URL in the deployed site. This allows the Swagger UI to reference it directly.
|
||||
|
||||
We only need one copy of the OpenAPI spec file, which is this one in the static directory.
|
||||
BIN
docs/static/img/oh-features.png
vendored
BIN
docs/static/img/oh-features.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
2085
docs/static/openapi.json
vendored
2085
docs/static/openapi.json
vendored
File diff suppressed because it is too large
Load Diff
628
docs/yarn.lock
628
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,3 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npx lint-staged
|
||||
|
||||
# Run backend pre-commit
|
||||
echo "Running backend pre-commit..."
|
||||
cd ..
|
||||
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// select a repository from the dropdown
|
||||
const dropdown = await waitFor(() =>
|
||||
const dropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown")
|
||||
);
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
@@ -23,7 +23,6 @@ const MOCK_TASK_1: SuggestedTask = {
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
const MOCK_TASK_2: SuggestedTask = {
|
||||
@@ -31,7 +30,6 @@ const MOCK_TASK_2: SuggestedTask = {
|
||||
repo: "repo2",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
const MOCK_TASK_3: SuggestedTask = {
|
||||
@@ -39,7 +37,6 @@ const MOCK_TASK_3: SuggestedTask = {
|
||||
repo: "repo3",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "gitlab",
|
||||
};
|
||||
|
||||
const MOCK_TASK_4: SuggestedTask = {
|
||||
@@ -47,7 +44,6 @@ const MOCK_TASK_4: SuggestedTask = {
|
||||
repo: "repo4",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "gitlab",
|
||||
};
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
@@ -123,11 +119,7 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[0],
|
||||
getMergeConflictPrompt(
|
||||
MOCK_TASK_1.git_provider,
|
||||
MOCK_TASK_1.issue_number,
|
||||
MOCK_TASK_1.repo,
|
||||
),
|
||||
getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
@@ -143,11 +135,7 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[1],
|
||||
getFailingChecksPrompt(
|
||||
MOCK_TASK_2.git_provider,
|
||||
MOCK_TASK_2.issue_number,
|
||||
MOCK_TASK_2.repo,
|
||||
),
|
||||
getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
@@ -163,11 +151,7 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[2],
|
||||
getUnresolvedCommentsPrompt(
|
||||
MOCK_TASK_3.git_provider,
|
||||
MOCK_TASK_3.issue_number,
|
||||
MOCK_TASK_3.repo,
|
||||
),
|
||||
getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
@@ -183,11 +167,7 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[3],
|
||||
getOpenIssuePrompt(
|
||||
MOCK_TASK_4.git_provider,
|
||||
MOCK_TASK_4.issue_number,
|
||||
MOCK_TASK_4.repo,
|
||||
),
|
||||
getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ const mock_provider_tokens_are_set: Record<Provider, boolean> = {
|
||||
describe("Settings Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const resetSettingsSpy = vi.spyOn(OpenHands, "resetSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
@@ -66,6 +67,7 @@ describe("Settings Screen", () => {
|
||||
// Use queryAllByText to handle multiple elements with the same text
|
||||
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(0);
|
||||
screen.getByText("ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS");
|
||||
screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
screen.getByText("BUTTON$SAVE");
|
||||
});
|
||||
});
|
||||
@@ -540,6 +542,54 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// Mock the settings that will be returned after reset
|
||||
// This should be the default settings with no advanced settings enabled
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
security_analyzer: "",
|
||||
});
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("base-url-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should save if only confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
@@ -712,6 +762,81 @@ describe("Settings Screen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const languageInput = await screen.findByTestId("language-input");
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = await screen.findByText("Norsk");
|
||||
await user.click(norskOption);
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Mock the settings response after reset
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
security_analyzer: "",
|
||||
});
|
||||
|
||||
// Wait for the mutation to complete and the modal to be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
const cancelButton = within(modal).getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with true if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
@@ -919,5 +1044,18 @@ describe("Settings Screen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not submit the unwanted fields when resetting", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(resetSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,35 +11,30 @@ const rawTasks: SuggestedTask[] = [
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 2,
|
||||
repo: "repo1",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 3,
|
||||
repo: "repo2",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 4,
|
||||
repo: "repo2",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 5,
|
||||
repo: "repo3",
|
||||
title: "Task 5",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -52,14 +47,12 @@ const groupedTasks: SuggestedTaskGroup[] = [
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 2,
|
||||
repo: "repo1",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -71,14 +64,12 @@ const groupedTasks: SuggestedTaskGroup[] = [
|
||||
repo: "repo2",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 4,
|
||||
repo: "repo2",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -90,7 +81,6 @@ const groupedTasks: SuggestedTaskGroup[] = [
|
||||
repo: "repo3",
|
||||
title: "Task 5",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
582
frontend/package-lock.json
generated
582
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,12 @@
|
||||
"@heroui/react": "2.7.6",
|
||||
"@microlink/react-json-view": "^1.26.1",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.5.2",
|
||||
"@react-router/serve": "^7.5.2",
|
||||
"@react-router/node": "^7.5.1",
|
||||
"@react-router/serve": "^7.5.1",
|
||||
"@react-types/shared": "^3.29.0",
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@stripe/react-stripe-js": "^3.6.0",
|
||||
"@stripe/stripe-js": "^7.2.0",
|
||||
"@stripe/stripe-js": "^7.1.0",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -23,31 +23,31 @@
|
||||
"axios": "^1.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.9.1",
|
||||
"i18next": "^25.0.1",
|
||||
"framer-motion": "^12.7.4",
|
||||
"i18next": "^25.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.5",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.25",
|
||||
"jose": "^6.0.10",
|
||||
"lucide-react": "^0.503.0",
|
||||
"lucide-react": "^0.501.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.236.6",
|
||||
"posthog-js": "^1.236.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.5.2",
|
||||
"react-router": "^7.5.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"vite": "^6.3.3",
|
||||
"vite": "^6.3.2",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
@@ -82,7 +82,7 @@
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@react-router/dev": "^7.5.2",
|
||||
"@react-router/dev": "^7.5.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.73.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
@@ -97,7 +97,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
|
||||
@@ -345,6 +345,7 @@ function isCommonDevelopmentString(str) {
|
||||
|
||||
// Check for specific error messages and UI text
|
||||
if (
|
||||
str === "Failed to accept TOS:" ||
|
||||
str === "All data associated with this project will be lost." ||
|
||||
str === "You will lose any unsaved information." ||
|
||||
str ===
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import axios from "axios";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
export const openHands = axios.create({
|
||||
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
|
||||
});
|
||||
|
||||
// We don't need the request interceptor anymore since we're using a separate QueryClient
|
||||
// for the TOS page that disables all queries
|
||||
|
||||
// Add response interceptor to handle 401 and 403 errors
|
||||
openHands.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Save the last page before redirecting
|
||||
saveLastPage();
|
||||
} else if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.tos_not_accepted
|
||||
) {
|
||||
// Save the last page before redirecting to TOS
|
||||
// Only redirect if we're not already on the TOS page
|
||||
if (window.location.pathname !== "/tos") {
|
||||
saveLastPage();
|
||||
window.location.href = "/tos";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -199,6 +199,14 @@ class OpenHands {
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user settings in server
|
||||
*/
|
||||
static async resetSettings(): Promise<boolean> {
|
||||
const response = await openHands.post("/api/reset-settings");
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
@@ -272,6 +280,15 @@ class OpenHands {
|
||||
await openHands.post(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the Terms of Service
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
static async acceptTOS(): Promise<boolean> {
|
||||
const response = await openHands.post("/api/accept_tos");
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
|
||||
const { data } = await openHands.get<GitChange[]>(
|
||||
`/api/conversations/${conversationId}/git/changes`,
|
||||
|
||||
@@ -1,94 +1,48 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTaskType } from "./task.types";
|
||||
|
||||
// Helper function to get provider-specific terminology
|
||||
const getProviderTerms = (git_provider: Provider) => {
|
||||
if (git_provider === "gitlab") {
|
||||
return {
|
||||
requestType: "Merge Request",
|
||||
requestTypeShort: "MR",
|
||||
apiName: "GitLab API",
|
||||
tokenEnvVar: "GITLAB_TOKEN",
|
||||
ciSystem: "CI pipelines",
|
||||
ciProvider: "GitLab",
|
||||
requestVerb: "merge request",
|
||||
};
|
||||
}
|
||||
return {
|
||||
requestType: "Pull Request",
|
||||
requestTypeShort: "PR",
|
||||
apiName: "GitHub API",
|
||||
tokenEnvVar: "GITHUB_TOKEN",
|
||||
ciSystem: "GitHub Actions",
|
||||
ciProvider: "GitHub",
|
||||
requestVerb: "pull request",
|
||||
};
|
||||
};
|
||||
|
||||
export const getMergeConflictPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
|
||||
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR'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.`;
|
||||
};
|
||||
|
||||
export const getFailingChecksPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then use the ${terms.apiName} to look at the ${terms.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 ${terms.ciProvider} ${terms.ciSystem.toLowerCase()} have run again. If they are still failing, repeat the process.`;
|
||||
};
|
||||
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
|
||||
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
|
||||
Then use the GitHub API to look at the GitHub Actions 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 GitHub actions have run again. If they are still failing, repeat the process.`;
|
||||
|
||||
export const getUnresolvedCommentsPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then use the ${terms.apiName} to retrieve all the feedback on the ${terms.requestTypeShort} so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
|
||||
};
|
||||
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
|
||||
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
|
||||
Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
|
||||
|
||||
export const getOpenIssuePrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue.
|
||||
Use the ${terms.apiName} with the ${terms.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 ${terms.requestVerb}. Be sure to reference the issue in the ${terms.requestTypeShort} description.`;
|
||||
};
|
||||
) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue
|
||||
Use the GitHub API 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 pull request. Be sure to reference the issue in the PR description`;
|
||||
|
||||
export const getPromptForQuery = (
|
||||
git_provider: Provider,
|
||||
type: SuggestedTaskType,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
switch (type) {
|
||||
case "MERGE_CONFLICTS":
|
||||
return getMergeConflictPrompt(git_provider, issueNumber, repo);
|
||||
return getMergeConflictPrompt(issueNumber, repo);
|
||||
case "FAILING_CHECKS":
|
||||
return getFailingChecksPrompt(git_provider, issueNumber, repo);
|
||||
return getFailingChecksPrompt(issueNumber, repo);
|
||||
case "UNRESOLVED_COMMENTS":
|
||||
return getUnresolvedCommentsPrompt(git_provider, issueNumber, repo);
|
||||
return getUnresolvedCommentsPrompt(issueNumber, repo);
|
||||
case "OPEN_ISSUE":
|
||||
return getOpenIssuePrompt(git_provider, issueNumber, repo);
|
||||
return getOpenIssuePrompt(issueNumber, repo);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { getPromptForQuery } from "./get-prompt-for-query";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
t: (key: string) => string,
|
||||
@@ -27,21 +26,18 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getRepo = (repo: string, git_provider: Provider) => {
|
||||
const getRepo = (repo: string) => {
|
||||
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
|
||||
const selectedRepo = repositoriesList?.find(
|
||||
(repository) =>
|
||||
repository.full_name === repo &&
|
||||
repository.git_provider === git_provider,
|
||||
(repository) => repository.full_name === repo,
|
||||
);
|
||||
|
||||
return selectedRepo;
|
||||
};
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
const repo = getRepo(task.repo);
|
||||
const query = getPromptForQuery(
|
||||
task.git_provider,
|
||||
task.task_type,
|
||||
task.issue_number,
|
||||
task.repo,
|
||||
@@ -53,16 +49,8 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// Determine the correct URL format based on git provider
|
||||
let href: string;
|
||||
if (task.git_provider === "gitlab") {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
|
||||
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
|
||||
} else {
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
}
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
|
||||
return (
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export type SuggestedTaskType =
|
||||
| "MERGE_CONFLICTS"
|
||||
| "FAILING_CHECKS"
|
||||
@@ -7,7 +5,6 @@ export type SuggestedTaskType =
|
||||
| "OPEN_ISSUE"; // This is a task type identifier, not a UI string
|
||||
|
||||
export interface SuggestedTask {
|
||||
git_provider: Provider;
|
||||
issue_number: number;
|
||||
repo: string;
|
||||
title: string;
|
||||
|
||||
@@ -75,7 +75,8 @@ export function Sidebar() {
|
||||
|
||||
const handleEndSession = () => {
|
||||
dispatch(setCurrentAgentState(AgentState.LOADING));
|
||||
endSession();
|
||||
// Always clear the last page when starting a new project
|
||||
endSession(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
interface AuthModalProps {
|
||||
githubAuthUrl: string | null;
|
||||
@@ -28,6 +29,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
// Save the current path before redirecting to auth
|
||||
saveLastPage();
|
||||
handleCaptureConsent(true);
|
||||
window.location.href = githubAuthUrl;
|
||||
}
|
||||
@@ -35,6 +38,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
if (gitlabAuthUrl) {
|
||||
// Save the current path before redirecting to auth
|
||||
saveLastPage();
|
||||
handleCaptureConsent(true);
|
||||
window.location.href = gitlabAuthUrl;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function TOSCheckbox({ onChange }: TOSCheckboxProps) {
|
||||
<span>
|
||||
{t(I18nKey.TOS$ACCEPT)}{" "}
|
||||
<a
|
||||
href="https://www.all-hands.dev/tos"
|
||||
href="/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 text-blue-500 hover:text-blue-700"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { clearLastPage } from "../utils/last-page";
|
||||
|
||||
interface AuthContextType {
|
||||
providerTokensSet: Provider[];
|
||||
setProviderTokensSet: (tokens: Provider[]) => void;
|
||||
providersAreSet: boolean;
|
||||
setProvidersAreSet: (status: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
interface AuthContextProps extends React.PropsWithChildren {
|
||||
@@ -28,14 +30,20 @@ function AuthProvider({
|
||||
initialProvidersAreSet,
|
||||
);
|
||||
|
||||
const logout = React.useCallback(() => {
|
||||
// Clear the last page before logging out
|
||||
clearLastPage();
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
providerTokensSet,
|
||||
setProviderTokensSet,
|
||||
providersAreSet,
|
||||
setProvidersAreSet,
|
||||
logout,
|
||||
}),
|
||||
[providerTokensSet],
|
||||
[providerTokensSet, logout],
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useConfig } from "../query/use-config";
|
||||
import { clearLastPage } from "#/utils/last-page";
|
||||
|
||||
export const useLogout = () => {
|
||||
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
|
||||
@@ -25,6 +26,9 @@ export const useLogout = () => {
|
||||
setProviderTokensSet([]);
|
||||
setProvidersAreSet(false);
|
||||
|
||||
// Clear the last page from localStorage
|
||||
clearLastPage();
|
||||
|
||||
// Navigate to root page and refresh the page
|
||||
navigate("/");
|
||||
window.location.reload();
|
||||
|
||||
@@ -3,8 +3,17 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { PostSettings, PostApiSettings } from "#/types/settings";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
|
||||
|
||||
const saveSettingsMutationFn = async (
|
||||
settings: Partial<PostSettings> | null,
|
||||
) => {
|
||||
// If settings is null, we're resetting
|
||||
if (settings === null) {
|
||||
await OpenHands.resetSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
const apiSettings: Partial<PostApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
llm_base_url: settings.LLM_BASE_URL,
|
||||
@@ -29,14 +38,27 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
export const useSaveSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: currentSettings } = useSettings();
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Partial<PostSettings>) => {
|
||||
mutationFn: async (settings: Partial<PostSettings> | null) => {
|
||||
// Skip API calls on TOS page
|
||||
if (disableApiCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings === null) {
|
||||
await saveSettingsMutationFn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
await saveSettingsMutationFn(newSettings);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
if (!disableApiCalls) {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
|
||||
|
||||
export const useBalance = () => {
|
||||
const { data: config } = useConfig();
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "balance"],
|
||||
queryFn: OpenHands.getBalance,
|
||||
enabled:
|
||||
config?.APP_MODE === "saas" && config?.FEATURE_FLAGS.ENABLE_BILLING,
|
||||
!disableApiCalls &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
|
||||
|
||||
export const useConfig = () =>
|
||||
useQuery({
|
||||
export const useConfig = () => {
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: OpenHands.getConfig,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes,
|
||||
enabled: !disableApiCalls,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,17 +3,19 @@ import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
|
||||
|
||||
export const useIsAuthed = () => {
|
||||
const { providersAreSet } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "authenticated", providersAreSet, appMode],
|
||||
queryFn: () => OpenHands.authenticate(appMode!),
|
||||
enabled: !!appMode,
|
||||
enabled: !!appMode && !disableApiCalls,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
retry: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import posthog from "posthog-js";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
|
||||
|
||||
const getSettingsQueryFn = async () => {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
@@ -29,6 +30,7 @@ const getSettingsQueryFn = async () => {
|
||||
export const useSettings = () => {
|
||||
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
|
||||
useAuth();
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["settings", providerTokensSet],
|
||||
@@ -39,6 +41,7 @@ export const useSettings = () => {
|
||||
retry: (_, error) => error.status !== 404,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: !disableApiCalls,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { generateAuthUrl } from "#/utils/generate-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useDisableApiOnTos } from "./use-disable-api-on-tos";
|
||||
|
||||
interface UseAuthUrlConfig {
|
||||
appMode: GetConfigResponse["APP_MODE"] | null;
|
||||
@@ -10,8 +11,14 @@ interface UseAuthUrlConfig {
|
||||
|
||||
export const useAuthUrl = (config: UseAuthUrlConfig) => {
|
||||
const { providersAreSet } = useAuth();
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
return React.useMemo(() => {
|
||||
// Disable auth URL generation on TOS page
|
||||
if (disableApiCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.appMode === "saas" && !providersAreSet) {
|
||||
try {
|
||||
return generateAuthUrl(
|
||||
@@ -25,5 +32,10 @@ export const useAuthUrl = (config: UseAuthUrlConfig) => {
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [providersAreSet, config.appMode, config.identityProvider]);
|
||||
}, [
|
||||
providersAreSet,
|
||||
config.appMode,
|
||||
config.identityProvider,
|
||||
disableApiCalls,
|
||||
]);
|
||||
};
|
||||
|
||||
26
frontend/src/hooks/use-disable-api-on-tos.ts
Normal file
26
frontend/src/hooks/use-disable-api-on-tos.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that returns whether API calls should be disabled
|
||||
* based on the current URL. Used to prevent infinite loops
|
||||
* on the TOS page.
|
||||
*
|
||||
* This hook doesn't use useLocation to avoid Router context requirements,
|
||||
* making it safe to use in components outside Router context.
|
||||
*/
|
||||
export const useDisableApiOnTos = (): boolean =>
|
||||
// Memoize the result to prevent unnecessary re-renders
|
||||
useMemo(() => {
|
||||
// Get the current URL path from window.location
|
||||
const currentPath = window.location.pathname;
|
||||
const currentUrl = window.location.href;
|
||||
|
||||
// Check if the current path is the TOS page
|
||||
const isTosPage = currentPath === "/tos";
|
||||
|
||||
// Also check if we're on an external TOS page that might be redirected
|
||||
const isExternalTosPage =
|
||||
currentPath.includes("/tos") || currentUrl.includes("all-hands.dev/tos");
|
||||
|
||||
return isTosPage || isExternalTosPage;
|
||||
}, []);
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
setUrl,
|
||||
} from "#/state/browser-slice";
|
||||
import { clearSelectedRepository } from "#/state/initial-query-slice";
|
||||
import { clearLastPage } from "#/utils/last-page";
|
||||
|
||||
export const useEndSession = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,14 +14,24 @@ export const useEndSession = () => {
|
||||
|
||||
/**
|
||||
* End the current session by clearing the token and redirecting to the home page.
|
||||
* @param {boolean} preserveLastPage - If true, don't clear the last page from localStorage
|
||||
*/
|
||||
const endSession = () => {
|
||||
const endSession = (preserveLastPage = false) => {
|
||||
dispatch(clearSelectedRepository());
|
||||
|
||||
// Reset browser state to initial values
|
||||
dispatch(setUrl(browserInitialState.url));
|
||||
dispatch(setScreenshotSrc(browserInitialState.screenshotSrc));
|
||||
|
||||
// Check if the current page is a conversation page
|
||||
const isConversationPage = window.location.pathname.includes("/conversations/");
|
||||
|
||||
// Clear the last page from localStorage unless preserveLastPage is true
|
||||
// and we're on a conversation page (to preserve the URL for after login)
|
||||
if (!preserveLastPage) {
|
||||
clearLastPage();
|
||||
}
|
||||
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { useEndSession } from "./use-end-session";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@@ -32,8 +33,11 @@ export const useHandleWSEvents = () => {
|
||||
|
||||
if (isServerError(event)) {
|
||||
if (event.error_code === 401) {
|
||||
// Save the last page before ending session
|
||||
saveLastPage();
|
||||
displayErrorToast("Session expired.");
|
||||
endSession();
|
||||
// Pass true to preserve the last page we just saved
|
||||
endSession(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React from "react";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
import { useDisableApiOnTos } from "./use-disable-api-on-tos";
|
||||
|
||||
export const useMigrateUserConsent = () => {
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
const disableApiCalls = useDisableApiOnTos();
|
||||
|
||||
/**
|
||||
* Migrate user consent to the settings store on the server.
|
||||
*/
|
||||
const migrateUserConsent = React.useCallback(
|
||||
async (args?: { handleAnalyticsWasPresentInLocalStorage: () => void }) => {
|
||||
// Skip migration on TOS page
|
||||
if (disableApiCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userAnalyticsConsent = localStorage.getItem("analytics-consent");
|
||||
|
||||
if (userAnalyticsConsent) {
|
||||
@@ -27,7 +34,7 @@ export const useMigrateUserConsent = () => {
|
||||
localStorage.removeItem("analytics-consent");
|
||||
}
|
||||
},
|
||||
[],
|
||||
[disableApiCalls],
|
||||
);
|
||||
|
||||
return { migrateUserConsent };
|
||||
|
||||
30
frontend/src/hooks/use-post-login-redirect.ts
Normal file
30
frontend/src/hooks/use-post-login-redirect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
import { getLastPage, clearLastPage } from "../utils/last-page";
|
||||
|
||||
export const usePostLoginRedirect = (isLoggedIn: boolean) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
// Check if there's a saved last page
|
||||
const lastPage = getLastPage();
|
||||
|
||||
// Only redirect if:
|
||||
// 1. There is a saved last page
|
||||
// 2. The user is currently on the root page (/) or a generic page
|
||||
// 3. The user is not already on the saved page
|
||||
const isOnGenericPage = location.pathname === "/" ||
|
||||
location.pathname === "/login" ||
|
||||
location.pathname === "/tos";
|
||||
|
||||
if (lastPage && isOnGenericPage && location.pathname !== lastPage) {
|
||||
navigate(lastPage);
|
||||
}
|
||||
|
||||
// Always clear the last page after login, whether we redirected or not
|
||||
clearLastPage();
|
||||
}
|
||||
}, [isLoggedIn, navigate, location.pathname]);
|
||||
};
|
||||
@@ -82,6 +82,7 @@ export enum I18nKey {
|
||||
API$DONT_KNOW_KEY = "API$DONT_KNOW_KEY",
|
||||
BUTTON$SAVE = "BUTTON$SAVE",
|
||||
BUTTON$CLOSE = "BUTTON$CLOSE",
|
||||
BUTTON$RESET_TO_DEFAULTS = "BUTTON$RESET_TO_DEFAULTS",
|
||||
MODAL$CONFIRM_RESET_TITLE = "MODAL$CONFIRM_RESET_TITLE",
|
||||
MODAL$CONFIRM_RESET_MESSAGE = "MODAL$CONFIRM_RESET_MESSAGE",
|
||||
MODAL$END_SESSION_TITLE = "MODAL$END_SESSION_TITLE",
|
||||
@@ -208,6 +209,11 @@ export enum I18nKey {
|
||||
BADGE$BETA = "BADGE$BETA",
|
||||
AGENT$RESUME_TASK = "AGENT$RESUME_TASK",
|
||||
AGENT$PAUSE_TASK = "AGENT$PAUSE_TASK",
|
||||
TOS$WELCOME = "TOS$WELCOME",
|
||||
TOS$REVIEW = "TOS$REVIEW",
|
||||
TOS$HEADER = "TOS$HEADER",
|
||||
TOS$MESSAGE = "TOS$MESSAGE",
|
||||
TOS$ACCEPT_BUTTON = "TOS$ACCEPT_BUTTON",
|
||||
TOS$ACCEPT = "TOS$ACCEPT",
|
||||
TOS$TERMS = "TOS$TERMS",
|
||||
USER$ACCOUNT_SETTINGS = "USER$ACCOUNT_SETTINGS",
|
||||
@@ -320,6 +326,7 @@ export enum I18nKey {
|
||||
SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL = "SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL",
|
||||
SETTINGS_FORM$SAVE_LABEL = "SETTINGS_FORM$SAVE_LABEL",
|
||||
SETTINGS_FORM$CLOSE_LABEL = "SETTINGS_FORM$CLOSE_LABEL",
|
||||
SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL = "SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL",
|
||||
SETTINGS_FORM$CANCEL_LABEL = "SETTINGS_FORM$CANCEL_LABEL",
|
||||
SETTINGS_FORM$END_SESSION_LABEL = "SETTINGS_FORM$END_SESSION_LABEL",
|
||||
SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE = "SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE",
|
||||
@@ -338,7 +345,6 @@ export enum I18nKey {
|
||||
STATUS$LLM_RETRY = "STATUS$LLM_RETRY",
|
||||
AGENT_ERROR$BAD_ACTION = "AGENT_ERROR$BAD_ACTION",
|
||||
AGENT_ERROR$ACTION_TIMEOUT = "AGENT_ERROR$ACTION_TIMEOUT",
|
||||
AGENT_ERROR$TOO_MANY_CONVERSATIONS = "AGENT_ERROR$TOO_MANY_CONVERSATIONS",
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL",
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL",
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",
|
||||
|
||||
@@ -1231,6 +1231,21 @@
|
||||
"tr": "Kapat",
|
||||
"de": "Schließen"
|
||||
},
|
||||
"BUTTON$RESET_TO_DEFAULTS": {
|
||||
"en": "Reset to defaults",
|
||||
"ja": "デフォルトにリセット",
|
||||
"zh-CN": "重置为默认值",
|
||||
"zh-TW": "還原為預設值",
|
||||
"ko-KR": "기본값으로 재설정",
|
||||
"no": "Tilbakestill til standard",
|
||||
"it": "Ripristina valori predefiniti",
|
||||
"pt": "Restaurar padrões",
|
||||
"es": "Restablecer valores predeterminados",
|
||||
"ar": "إعادة التعيين إلى الإعدادات الافتراضية",
|
||||
"fr": "Réinitialiser aux valeurs par défaut",
|
||||
"tr": "Varsayılanlara sıfırla",
|
||||
"de": "Auf Standardwerte zurücksetzen"
|
||||
},
|
||||
"MODAL$CONFIRM_RESET_TITLE": {
|
||||
"en": "Are you sure?",
|
||||
"ja": "本当によろしいですか?",
|
||||
@@ -4526,6 +4541,21 @@
|
||||
"pt": "Fechar",
|
||||
"tr": "Kapat"
|
||||
},
|
||||
"SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL": {
|
||||
"en": "Reset to defaults",
|
||||
"es": "Reiniciar valores por defect",
|
||||
"zh-CN": "重置为默认值",
|
||||
"zh-TW": "還原為預設值",
|
||||
"ko-KR": "기본값으로 재설정",
|
||||
"ja": "デフォルトに戻す",
|
||||
"no": "Tilbakestill til standardverdier",
|
||||
"ar": "إعادة التعيين إلى الإعدادات الافتراضية",
|
||||
"de": "Auf Standardwerte zurücksetzen",
|
||||
"fr": "Réinitialiser aux valeurs par défaut",
|
||||
"it": "Ripristina valori predefiniti",
|
||||
"pt": "Restaurar padrões",
|
||||
"tr": "Varsayılanlara sıfırla"
|
||||
},
|
||||
"SETTINGS_FORM$CANCEL_LABEL": {
|
||||
"en": "Cancel",
|
||||
"es": "Cancelar",
|
||||
@@ -4796,9 +4826,6 @@
|
||||
"es": "La acción expiró",
|
||||
"tr": "İşlem zaman aşımına uğradı"
|
||||
},
|
||||
"AGENT_ERROR$TOO_MANY_CONVERSATIONS": {
|
||||
"en": "Too many conversations at once."
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
|
||||
"en": "Connect to GitHub",
|
||||
"es": "Conectar a GitHub",
|
||||
|
||||
@@ -7,7 +7,6 @@ const TASKS_1: SuggestedTask[] = [
|
||||
title: "Fix merge conflicts",
|
||||
repo: "octocat/hello-world",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,63 +16,54 @@ const TASKS_2: SuggestedTask[] = [
|
||||
title: "Fix broken CI checks",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 281,
|
||||
title: "Fix issue",
|
||||
repo: "octocat/earth",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 293,
|
||||
title: "Update documentation",
|
||||
repo: "octocat/earth",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 305,
|
||||
title: "Refactor user service",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 312,
|
||||
title: "Fix styling bug",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 327,
|
||||
title: "Add unit tests",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 331,
|
||||
title: "Implement dark mode",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 345,
|
||||
title: "Optimize build process",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 352,
|
||||
title: "Update dependencies",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
export default [
|
||||
layout("routes/root-layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("tos", "routes/tos-wrapper.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/account-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingsDropdownInput } from "#/components/features/settings/settings-d
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
@@ -94,6 +95,8 @@ function AccountSettings() {
|
||||
>(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
|
||||
React.useState(!!settings?.SECURITY_ANALYZER);
|
||||
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -177,6 +180,16 @@ function AccountSettings() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
saveSettings(null, {
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$RESET));
|
||||
setResetSettingsModalIsOpen(false);
|
||||
setLlmConfigMode("basic");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings is still loading by the time the state is set, it will always
|
||||
// default to basic settings. This is a workaround to ensure the correct
|
||||
@@ -514,6 +527,13 @@ function AccountSettings() {
|
||||
</form>
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setResetSettingsModalIsOpen(true)}
|
||||
>
|
||||
{t(I18nKey.BUTTON$RESET_TO_DEFAULTS)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -524,6 +544,40 @@ function AccountSettings() {
|
||||
{t(I18nKey.BUTTON$SAVE)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
|
||||
{resetSettingsModalIsOpen && (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="reset-modal"
|
||||
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
|
||||
>
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
setResetSettingsModalIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { clearFiles, clearInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
@@ -58,14 +59,19 @@ function AppContent() {
|
||||
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
|
||||
// Use the useIsAuthed hook to check authentication status
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isFetched && !conversation) {
|
||||
// Only show error and redirect if the user is authenticated
|
||||
// This prevents the error when a user is trying to log in to access a conversation
|
||||
if (isAuthed && isFetched && !conversation) {
|
||||
displayErrorToast(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
endSession();
|
||||
}
|
||||
}, [conversation, isFetched]);
|
||||
}, [conversation, isFetched, isAuthed]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearMessages());
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { usePostLoginRedirect } from "#/hooks/use-post-login-redirect";
|
||||
import { saveLastPage } from "#/utils/last-page";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -101,6 +103,11 @@ export default function MainApp() {
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Skip API-related redirects on the TOS page
|
||||
if (pathname === "/tos") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow users to use the app if it 402s
|
||||
if (error?.status === 402 && pathname !== "/") {
|
||||
navigate("/");
|
||||
@@ -111,9 +118,31 @@ export default function MainApp() {
|
||||
}
|
||||
}, [error?.status, pathname, isFetching]);
|
||||
|
||||
const userIsAuthed = !!isAuthed && !authError;
|
||||
// Check if we're on the TOS page
|
||||
const isOnTosPage = pathname === "/tos";
|
||||
|
||||
// Only consider the user authenticated if we have a valid auth response
|
||||
// or if we're on the TOS page (to prevent auth modal)
|
||||
const userIsAuthed = (!!isAuthed && !authError) || isOnTosPage;
|
||||
|
||||
// Don't show auth modal on TOS page to prevent infinite loop
|
||||
const renderAuthModal =
|
||||
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
|
||||
!isFetchingAuth &&
|
||||
!userIsAuthed &&
|
||||
config.data?.APP_MODE === "saas" &&
|
||||
!isOnTosPage;
|
||||
|
||||
// Handle redirection to last page after login
|
||||
usePostLoginRedirect(userIsAuthed);
|
||||
|
||||
// Track page visits for last page functionality
|
||||
React.useEffect(() => {
|
||||
// Always save conversation URLs, even when not authenticated
|
||||
if (pathname && (userIsAuthed || pathname.includes("/conversations/"))) {
|
||||
// Save the current page for future reference
|
||||
saveLastPage();
|
||||
}
|
||||
}, [pathname, userIsAuthed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
37
frontend/src/routes/tos-wrapper.tsx
Normal file
37
frontend/src/routes/tos-wrapper.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import TOSPage from "./tos";
|
||||
|
||||
/**
|
||||
* This component wraps the TOS page with a separate QueryClient
|
||||
* to prevent any API calls from being made while on the TOS page.
|
||||
*/
|
||||
export default function TOSWrapper() {
|
||||
// Create a new QueryClient with defaultOptions that disable all queries
|
||||
const queryClient = React.useMemo(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Disable all queries by default
|
||||
enabled: false,
|
||||
// Don't retry failed queries
|
||||
retry: false,
|
||||
// Don't refetch on window focus
|
||||
refetchOnWindowFocus: false,
|
||||
// Don't refetch on reconnect
|
||||
refetchOnReconnect: false,
|
||||
// Don't refetch on mount
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TOSPage />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
119
frontend/src/routes/tos.tsx
Normal file
119
frontend/src/routes/tos.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
export default function TOSPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Set default values for queries to prevent API calls
|
||||
useEffect(() => {
|
||||
// Set default values for config
|
||||
queryClient.setQueryData(["config"], {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: null,
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Set default values for settings
|
||||
queryClient.setQueryData(["settings"], DEFAULT_SETTINGS);
|
||||
|
||||
// Set default values for authentication
|
||||
queryClient.setQueryData(["user", "authenticated"], true);
|
||||
|
||||
// Set default values for balance
|
||||
queryClient.setQueryData(["user", "balance"], { credits: 0 });
|
||||
|
||||
// Prevent any API calls while on this page
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
// Allow only the accept_tos endpoint
|
||||
if (typeof input === "string" && input.includes("/api/accept_tos")) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
// Silent block of fetch calls on TOS page
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ blocked: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return () => {
|
||||
// Restore original fetch
|
||||
window.fetch = originalFetch;
|
||||
|
||||
// Clear the cache when leaving the TOS page
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "authenticated"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "balance"] });
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Use a direct axios call instead of the OpenHands API client
|
||||
// to avoid triggering the interceptors and other API calls
|
||||
const handleAcceptTOS = useCallback(async () => {
|
||||
try {
|
||||
// Create a new axios instance without interceptors
|
||||
const baseURL = `${window.location.protocol}//${window?.location.host}`;
|
||||
const response = await axios.post(`${baseURL}/api/accept_tos`);
|
||||
|
||||
if (response.status === 200) {
|
||||
// Get the last page from localStorage or default to root
|
||||
const lastPage = localStorage.getItem("openhandsLastPage") || "/";
|
||||
|
||||
// Instead of using navigate, do a full page reload to ensure
|
||||
// all auth state is properly refreshed
|
||||
window.location.href = lastPage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to accept TOS:", error);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<div className="max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<h1 className="text-3xl font-bold mb-6">{t(I18nKey.TOS$HEADER)}</h1>
|
||||
|
||||
<div className="prose dark:prose-invert mb-8">
|
||||
<p>{t(I18nKey.TOS$WELCOME)}</p>
|
||||
|
||||
<div className="my-6">
|
||||
<p className="mb-4">{t(I18nKey.TOS$REVIEW)}</p>
|
||||
<a
|
||||
href="https://www.all-hands.dev/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 underline"
|
||||
>
|
||||
https://www.all-hands.dev/tos
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mt-6">{t(I18nKey.TOS$MESSAGE)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={handleAcceptTOS}
|
||||
>
|
||||
{t(I18nKey.TOS$ACCEPT_BUTTON)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,16 +14,14 @@ export function groupSuggestedTasks(
|
||||
const groupsMap: Record<string, SuggestedTaskGroup> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
const groupKey = `${task.repo}`;
|
||||
|
||||
if (!groupsMap[groupKey]) {
|
||||
groupsMap[groupKey] = {
|
||||
title: groupKey,
|
||||
if (!groupsMap[task.repo]) {
|
||||
groupsMap[task.repo] = {
|
||||
title: task.repo,
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
groupsMap[groupKey].tasks.push(task);
|
||||
groupsMap[task.repo].tasks.push(task);
|
||||
}
|
||||
|
||||
return Object.values(groupsMap);
|
||||
|
||||
33
frontend/src/utils/last-page.ts
Normal file
33
frontend/src/utils/last-page.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
const LAST_PAGE_KEY = "openhandsLastPage";
|
||||
|
||||
export const saveLastPage = () => {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
// Always save conversation URLs, even before login
|
||||
if (currentPath.includes("/conversations/")) {
|
||||
localStorage.setItem(LAST_PAGE_KEY, currentPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't save root, tos, or settings pages
|
||||
if (
|
||||
!currentPath.includes("/settings") &&
|
||||
currentPath !== "/" &&
|
||||
currentPath !== "/tos"
|
||||
) {
|
||||
localStorage.setItem(LAST_PAGE_KEY, currentPath);
|
||||
} else if (currentPath === "/" && window.location.search) {
|
||||
// If we're on the root page but have query parameters, save the full URL
|
||||
localStorage.setItem(
|
||||
LAST_PAGE_KEY,
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLastPage = (): string | null =>
|
||||
localStorage.getItem(LAST_PAGE_KEY);
|
||||
|
||||
export const clearLastPage = () => {
|
||||
localStorage.removeItem(LAST_PAGE_KEY);
|
||||
};
|
||||
@@ -414,7 +414,7 @@ class AgentController:
|
||||
should_step = self.should_step(event)
|
||||
if should_step:
|
||||
self.log(
|
||||
'debug',
|
||||
'info',
|
||||
f'Stepping agent after event: {type(event).__name__}',
|
||||
extra={'msg_type': 'STEPPING_AGENT'},
|
||||
)
|
||||
@@ -779,7 +779,7 @@ class AgentController:
|
||||
return
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
'info',
|
||||
f'LEVEL {self.state.delegate_level} LOCAL STEP {self.state.local_iteration} GLOBAL STEP {self.state.iteration}',
|
||||
extra={'msg_type': 'STEP'},
|
||||
)
|
||||
@@ -968,7 +968,7 @@ class AgentController:
|
||||
action_type = type(prev_action).__name__
|
||||
elapsed_time = time.time() - timestamp
|
||||
self.log(
|
||||
'debug',
|
||||
'info',
|
||||
f'Cleared pending action after {elapsed_time:.2f}s: {action_type} (id={action_id})',
|
||||
extra={'msg_type': 'PENDING_ACTION_CLEARED'},
|
||||
)
|
||||
@@ -977,7 +977,7 @@ class AgentController:
|
||||
action_id = getattr(action, 'id', 'unknown')
|
||||
action_type = type(action).__name__
|
||||
self.log(
|
||||
'debug',
|
||||
'info',
|
||||
f'Set pending action: {action_type} (id={action_id})',
|
||||
extra={'msg_type': 'PENDING_ACTION_SET'},
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ def event_to_dict(event: 'Event') -> dict:
|
||||
k: (v.value if isinstance(v, Enum) else _convert_pydantic_to_dict(v))
|
||||
for k, v in props.items()
|
||||
}
|
||||
logger.debug(f'extras data in event_to_dict: {d["extras"]}')
|
||||
# Include success field for CmdOutputObservation
|
||||
if hasattr(event, 'success'):
|
||||
d['success'] = event.success
|
||||
|
||||
@@ -18,8 +18,6 @@ from openhands.integrations.service_types import (
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
class GitHubService(BaseGitService, GitService):
|
||||
@@ -159,13 +157,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
|
||||
return repos[:max_repos] # Trim to max_repos if needed
|
||||
|
||||
|
||||
def parse_pushed_at_date(self, repo):
|
||||
ts = repo.get("pushed_at")
|
||||
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") if ts else datetime.min
|
||||
|
||||
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
@@ -192,11 +183,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
# If we've already reached MAX_REPOS, no need to check other installations
|
||||
if len(all_repos) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
if sort == "pushed":
|
||||
all_repos.sort(
|
||||
key=self.parse_pushed_at_date, reverse=True
|
||||
)
|
||||
else:
|
||||
# Original behavior for non-SaaS mode
|
||||
params = {'per_page': str(PER_PAGE), 'sort': sort}
|
||||
@@ -205,7 +191,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
# Fetch user repositories
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
|
||||
# Convert to Repository objects
|
||||
return [
|
||||
Repository(
|
||||
@@ -366,7 +351,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
if task_type != TaskType.OPEN_PR:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=pr['number'],
|
||||
@@ -379,7 +363,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
repo_name = issue['repository']['nameWithOwner']
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue['number'],
|
||||
|
||||
@@ -16,6 +16,6 @@ A comment on the issue has been addressed to you.
|
||||
When you're done, make sure to
|
||||
|
||||
1. Use the `GITHUB_TOKEN` environment variable and GitHub API to open a new PR
|
||||
2. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
|
||||
2. Name the branch using the format: `openhands/{branch-name}`
|
||||
3. The PR description should mention that it "fixes" or "closes" the issue number
|
||||
4. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
|
||||
|
||||
@@ -10,8 +10,6 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
@@ -108,7 +106,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def execute_graphql_query(self, query: str, variables: dict[str, Any] = {}) -> Any:
|
||||
async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Execute a GraphQL query against the GitLab GraphQL API
|
||||
|
||||
@@ -246,138 +244,6 @@ class GitLabService(BaseGitService, GitService):
|
||||
for repo in all_repos
|
||||
]
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
Returns:
|
||||
- Merge requests authored by the user.
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
username = user.login
|
||||
|
||||
# GraphQL query to get merge requests
|
||||
query = """
|
||||
query GetUserTasks {
|
||||
currentUser {
|
||||
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
|
||||
nodes {
|
||||
id
|
||||
iid
|
||||
title
|
||||
project {
|
||||
fullPath
|
||||
}
|
||||
conflicts
|
||||
mergeStatus
|
||||
pipelines(first: 1) {
|
||||
nodes {
|
||||
status
|
||||
}
|
||||
}
|
||||
discussions(first: 100) {
|
||||
nodes {
|
||||
notes {
|
||||
nodes {
|
||||
resolvable
|
||||
resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
# Get merge requests using GraphQL
|
||||
response = await self.execute_graphql_query(query)
|
||||
data = response.get('currentUser', {})
|
||||
|
||||
# Process merge requests
|
||||
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
|
||||
for mr in merge_requests:
|
||||
repo_name = mr.get('project', {}).get('fullPath', '')
|
||||
mr_number = mr.get('iid')
|
||||
title = mr.get('title', '')
|
||||
|
||||
# Start with default task type
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if mr.get('conflicts'):
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
mr.get('pipelines', {}).get('nodes', [])
|
||||
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
|
||||
== 'FAILED'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
else:
|
||||
# Check for unresolved comments
|
||||
has_unresolved_comments = False
|
||||
for discussion in mr.get('discussions', {}).get('nodes', []):
|
||||
for note in discussion.get('notes', {}).get('nodes', []):
|
||||
if note.get('resolvable') and not note.get('resolved'):
|
||||
has_unresolved_comments = True
|
||||
break
|
||||
if has_unresolved_comments:
|
||||
break
|
||||
|
||||
if has_unresolved_comments:
|
||||
task_type = TaskType.UNRESOLVED_COMMENTS
|
||||
|
||||
# Only add the task if it's not OPEN_PR
|
||||
if task_type != TaskType.OPEN_PR:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=mr_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
# Get assigned issues using REST API
|
||||
url = f"{self.BASE_URL}/issues"
|
||||
params = {
|
||||
"assignee_username": username,
|
||||
"state": "opened",
|
||||
"scope": "assigned_to_me"
|
||||
}
|
||||
|
||||
issues_response, _ = await self._make_request(
|
||||
method=RequestMethod.GET,
|
||||
url=url,
|
||||
params=params
|
||||
)
|
||||
|
||||
# Process issues
|
||||
for issue in issues_response:
|
||||
repo_name = issue.get('references', {}).get('full', '').split('#')[0].strip()
|
||||
issue_number = issue.get('iid')
|
||||
title = issue.get('title', '')
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -25,7 +25,6 @@ from openhands.integrations.service_types import (
|
||||
GitService,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
@@ -228,7 +227,7 @@ class ProviderHandler:
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""
|
||||
Get repositories from providers
|
||||
Get repositories from a selected providers with pagination support
|
||||
"""
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
@@ -242,21 +241,6 @@ class ProviderHandler:
|
||||
|
||||
return all_repos
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""
|
||||
Get suggested tasks from providers
|
||||
"""
|
||||
tasks: list[SuggestedTask] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
service_repos = await service.get_suggested_tasks()
|
||||
tasks.extend(service_repos)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {provider}: {e}')
|
||||
|
||||
return tasks
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -23,7 +23,6 @@ class TaskType(str, Enum):
|
||||
|
||||
|
||||
class SuggestedTask(BaseModel):
|
||||
git_provider: ProviderType
|
||||
task_type: TaskType
|
||||
repo: str
|
||||
issue_number: int
|
||||
@@ -111,7 +110,7 @@ class BaseGitService(ABC):
|
||||
return UnknownException('Unknown error')
|
||||
|
||||
def handle_http_error(self, e: HTTPError) -> UnknownException:
|
||||
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
|
||||
logger.warning(f'HTTP error on {self.provider} API: {e}')
|
||||
return UnknownException('Unknown error')
|
||||
|
||||
|
||||
@@ -150,7 +149,3 @@ class GitService(Protocol):
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories"""
|
||||
...
|
||||
|
||||
@@ -713,7 +713,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
completion_response=response, **extra_kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Error getting cost from litellm: {e}')
|
||||
logger.error(f'Error getting cost from litellm: {e}')
|
||||
|
||||
if cost is None:
|
||||
_model_name = '/'.join(self.config.model.split('/')[1:])
|
||||
|
||||
@@ -183,7 +183,7 @@ python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --use
|
||||
|
||||
## Providing Custom Instructions
|
||||
|
||||
You can customize how the AI agent approaches issue resolution by adding a repository microagent file at `.openhands/microagents/repo.md` in your repository. This file's contents will be automatically loaded in the prompt when working with your repository. For more information about repository microagents, see [Repository Instructions](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents#2-repository-instructions-private).
|
||||
You can customize how the AI agent approaches issue resolution by adding a `.openhands_instructions` file to the root of your repository. If present, this file's contents will be injected into the prompt for openhands edits.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -555,7 +555,7 @@ if __name__ == '__main__':
|
||||
# Start the file viewer server in a separate thread
|
||||
logger.info('Starting file viewer server')
|
||||
_file_viewer_port = find_available_tcp_port(
|
||||
min_port=args.port + 1, max_port=min(args.port + 1024, 65535)
|
||||
min_port=args.port + 1, max_port=args.port + 10000
|
||||
)
|
||||
server_url, _ = start_file_viewer_server(port=_file_viewer_port)
|
||||
logger.info(f'File viewer server started at {server_url}')
|
||||
|
||||
@@ -98,7 +98,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
initial_env_vars: dict[str, str]
|
||||
attach_to_existing: bool
|
||||
status_callback: Callable | None
|
||||
git_dir: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -113,11 +112,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
# GitHandler will be initialized with an async function
|
||||
self.git_handler = GitHandler(
|
||||
execute_shell_fn=self._execute_shell_fn_git_handler
|
||||
)
|
||||
self.git_dir = None
|
||||
self.sid = sid
|
||||
self.event_stream = event_stream
|
||||
self.event_stream.subscribe(
|
||||
@@ -319,9 +316,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
selected_branch: str | None,
|
||||
repository_provider: ProviderType = ProviderType.GITHUB,
|
||||
) -> str:
|
||||
# Set the git_dir to the workspace mount path by default
|
||||
self.git_dir = self.config.workspace_mount_path_in_sandbox
|
||||
|
||||
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
|
||||
@@ -333,7 +327,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
command='git init',
|
||||
)
|
||||
self.run_action(action)
|
||||
# git_dir is already set to workspace mount path
|
||||
else:
|
||||
logger.info(
|
||||
'In workspace mount mode, not initializing a new git repository.'
|
||||
@@ -402,13 +395,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
)
|
||||
self.log('info', f'Cloning repo: {selected_repository}')
|
||||
self.run_action(action)
|
||||
|
||||
# Update git_dir to point to the cloned repository directory
|
||||
self.git_dir = os.path.join(
|
||||
self.config.workspace_mount_path_in_sandbox, dir_name
|
||||
)
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
|
||||
return dir_name
|
||||
|
||||
def maybe_run_setup_script(self):
|
||||
@@ -626,15 +612,13 @@ class Runtime(FileEditRuntimeMixin):
|
||||
# Git
|
||||
# ====================================================================
|
||||
|
||||
async def _execute_shell_fn_git_handler(
|
||||
def _execute_shell_fn_git_handler(
|
||||
self, command: str, cwd: str | None
|
||||
) -> CommandResult:
|
||||
"""
|
||||
This function is used by the GitHandler to execute shell commands.
|
||||
"""
|
||||
obs = await call_sync_from_async(
|
||||
self.run, CmdRunAction(command=command, is_static=True, cwd=cwd)
|
||||
)
|
||||
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
|
||||
exit_code = 0
|
||||
content = ''
|
||||
|
||||
@@ -645,15 +629,13 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
return CommandResult(content=content, exit_code=exit_code)
|
||||
|
||||
async def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
if self.git_dir:
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
return await call_sync_from_async(self.git_handler.get_git_changes)
|
||||
def get_git_changes(self, cwd: str) -> list[dict[str, str]] | None:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_changes()
|
||||
|
||||
async def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
if self.git_dir:
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
return await call_sync_from_async(self.git_handler.get_git_diff, file_path)
|
||||
def get_git_diff(self, file_path: str, cwd: str) -> dict[str, str]:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_diff(file_path)
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
|
||||
@@ -68,10 +68,7 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
|
||||
import libtmux
|
||||
|
||||
server = libtmux.Server()
|
||||
try:
|
||||
session = server.new_session(session_name='test-session')
|
||||
except Exception:
|
||||
raise ValueError('tmux is not properly installed or available on the path.')
|
||||
session = server.new_session(session_name='test-session')
|
||||
pane = session.attached_pane
|
||||
pane.send_keys('echo "test"')
|
||||
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
|
||||
|
||||
@@ -7,7 +7,6 @@ import re
|
||||
from uuid import uuid4
|
||||
|
||||
import tornado
|
||||
import tornado.websocket
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
from tornado.escape import json_decode, json_encode, url_escape
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
@@ -140,7 +139,7 @@ class JupyterKernel:
|
||||
wait=wait_fixed(2),
|
||||
)
|
||||
async def execute(self, code: str, timeout: int = 120) -> str:
|
||||
if not self.ws or self.ws.stream.closed():
|
||||
if not self.ws:
|
||||
await self._connect()
|
||||
|
||||
msg_id = uuid4().hex
|
||||
|
||||
@@ -215,13 +215,13 @@ class BashSession:
|
||||
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), _global=True)
|
||||
self.session.history_limit = self.HISTORY_LIMIT
|
||||
# We need to create a new pane because the initial pane's history limit is (default) 2000
|
||||
_initial_window = self.session.active_window
|
||||
_initial_window = self.session.attached_window
|
||||
self.window = self.session.new_window(
|
||||
window_name='bash',
|
||||
window_shell=window_command,
|
||||
start_directory=self.work_dir, # This parameter is supported by libtmux
|
||||
)
|
||||
self.pane = self.window.active_pane
|
||||
self.pane = self.window.attached_pane
|
||||
logger.debug(f'pane: {self.pane}; history_limit: {self.session.history_limit}')
|
||||
_initial_window.kill_window()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
from typing import Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -23,7 +23,7 @@ class GitHandler:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execute_shell_fn: Callable[[str, str | None], Awaitable[CommandResult]],
|
||||
execute_shell_fn: Callable[[str, str | None], CommandResult],
|
||||
):
|
||||
self.execute = execute_shell_fn
|
||||
self.cwd: str | None = None
|
||||
@@ -37,11 +37,7 @@ class GitHandler:
|
||||
"""
|
||||
self.cwd = cwd
|
||||
|
||||
async def _execute_async(self, cmd: str, cwd: str | None) -> CommandResult:
|
||||
"""Execute the command asynchronously."""
|
||||
return await self.execute(cmd, cwd)
|
||||
|
||||
async def _is_git_repo(self) -> bool:
|
||||
def _is_git_repo(self) -> bool:
|
||||
"""
|
||||
Checks if the current directory is a Git repository.
|
||||
|
||||
@@ -49,10 +45,10 @@ class GitHandler:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
"""
|
||||
cmd = 'git rev-parse --is-inside-work-tree'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
async def _get_current_file_content(self, file_path: str) -> str:
|
||||
def _get_current_file_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the current content of a given file.
|
||||
|
||||
@@ -62,10 +58,10 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The file content.
|
||||
"""
|
||||
output = await self._execute_async(f'cat {file_path}', self.cwd)
|
||||
output = self.execute(f'cat {file_path}', self.cwd)
|
||||
return output.content
|
||||
|
||||
async def _verify_ref_exists(self, ref: str) -> bool:
|
||||
def _verify_ref_exists(self, ref: str) -> bool:
|
||||
"""
|
||||
Verifies whether a specific Git reference exists.
|
||||
|
||||
@@ -76,37 +72,28 @@ class GitHandler:
|
||||
bool: True if the reference exists, otherwise False.
|
||||
"""
|
||||
cmd = f'git rev-parse --verify {ref}'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
async def _get_valid_ref(self) -> str | None:
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
current_branch = await self._get_current_branch()
|
||||
default_branch = await self._get_default_branch()
|
||||
|
||||
ref_current_branch = f'origin/{current_branch}'
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{self._get_current_branch()})")'
|
||||
ref_default_branch = 'origin/' + self._get_current_branch()
|
||||
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
ref_non_default_branch,
|
||||
ref_default_branch,
|
||||
ref_new_repo,
|
||||
]
|
||||
refs = [ref_non_default_branch, ref_default_branch, ref_new_repo]
|
||||
for ref in refs:
|
||||
if await self._verify_ref_exists(ref):
|
||||
if self._verify_ref_exists(ref):
|
||||
return ref
|
||||
|
||||
return None
|
||||
|
||||
async def _get_ref_content(self, file_path: str) -> str:
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
|
||||
@@ -116,15 +103,15 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The content of the file from the reference, or an empty string if unavailable.
|
||||
"""
|
||||
ref = await self._get_valid_ref()
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return ''
|
||||
|
||||
cmd = f'git show {ref}:{file_path}'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
async def _get_default_branch(self) -> str:
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
@@ -132,36 +119,25 @@ class GitHandler:
|
||||
str: The name of the primary branch.
|
||||
"""
|
||||
cmd = 'git remote show origin | grep "HEAD branch"'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
async def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the currently selected Git branch.
|
||||
|
||||
Returns:
|
||||
str: The name of the current branch.
|
||||
"""
|
||||
cmd = 'git rev-parse --abbrev-ref HEAD'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
return output.content.strip()
|
||||
|
||||
async def _get_changed_files(self) -> list[str]:
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of changed file paths.
|
||||
"""
|
||||
ref = await self._get_valid_ref()
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
diff_cmd = f'git diff --name-status {ref}'
|
||||
output = await self._execute_async(diff_cmd, self.cwd)
|
||||
output = self.execute(diff_cmd, self.cwd)
|
||||
return output.content.splitlines()
|
||||
|
||||
async def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
|
||||
@@ -169,7 +145,7 @@ class GitHandler:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
"""
|
||||
cmd = 'git ls-files --others --exclude-standard'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
obs_list = output.content.splitlines()
|
||||
return (
|
||||
[{'status': 'A', 'path': path} for path in obs_list]
|
||||
@@ -177,24 +153,24 @@ class GitHandler:
|
||||
else []
|
||||
)
|
||||
|
||||
async def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""
|
||||
Retrieves the list of changed files in the Git repository.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
|
||||
"""
|
||||
if not await self._is_git_repo():
|
||||
if not self._is_git_repo():
|
||||
return None
|
||||
|
||||
changes_list = await self._get_changed_files()
|
||||
changes_list = self._get_changed_files()
|
||||
result = parse_git_changes(changes_list)
|
||||
|
||||
# join with any untracked files
|
||||
result += await self._get_untracked_files()
|
||||
result += self._get_untracked_files()
|
||||
return result
|
||||
|
||||
async def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""
|
||||
Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
@@ -204,8 +180,8 @@ class GitHandler:
|
||||
Returns:
|
||||
dict[str, str]: A dictionary containing the original and modified content.
|
||||
"""
|
||||
modified = await self._get_current_file_content(file_path)
|
||||
original = await self._get_ref_content(file_path)
|
||||
modified = self._get_current_file_content(file_path)
|
||||
original = self._get_ref_content(file_path)
|
||||
|
||||
return {
|
||||
'modified': modified,
|
||||
|
||||
34
openhands/server/auth.py
Normal file
34
openhands/server/auth.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
|
||||
|
||||
def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:
|
||||
"""Get GitHub token from request state. For backward compatibility."""
|
||||
return getattr(request.state, 'provider_tokens', None)
|
||||
|
||||
|
||||
def get_access_token(request: Request) -> SecretStr | None:
|
||||
return getattr(request.state, 'access_token', None)
|
||||
|
||||
|
||||
def get_user_id(request: Request) -> str | None:
|
||||
return getattr(request.state, 'user_id', None)
|
||||
|
||||
|
||||
def get_github_token(request: Request) -> SecretStr | None:
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
|
||||
if provider_tokens and ProviderType.GITHUB in provider_tokens:
|
||||
return provider_tokens[ProviderType.GITHUB].token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_github_user_id(request: Request) -> str | None:
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
if provider_tokens and ProviderType.GITHUB in provider_tokens:
|
||||
return provider_tokens[ProviderType.GITHUB].user_id
|
||||
|
||||
return None
|
||||
@@ -20,7 +20,6 @@ class ServerConfig(ServerConfigInterface):
|
||||
)
|
||||
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
|
||||
monitoring_listener_class: str = 'openhands.server.monitoring.MonitoringListener'
|
||||
user_auth_class: str = 'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
|
||||
|
||||
def verify_config(self):
|
||||
if self.config_cls:
|
||||
|
||||
@@ -202,7 +202,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
ConversationStore, # type: ignore
|
||||
self.server_config.conversation_store_class,
|
||||
)
|
||||
store = await conversation_store_class.get_instance(self.config, user_id)
|
||||
store = await conversation_store_class.get_instance(
|
||||
self.config, user_id, github_user_id
|
||||
)
|
||||
return store
|
||||
|
||||
async def get_running_agent_loops(
|
||||
@@ -283,7 +285,7 @@ class StandaloneConversationManager(ConversationManager):
|
||||
response_ids = await self.get_running_agent_loops(user_id)
|
||||
if len(response_ids) >= self.config.max_concurrent_conversations:
|
||||
logger.info(
|
||||
f'too_many_sessions_for:{user_id or ''}',
|
||||
'too_many_sessions_for:{user_id}',
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
)
|
||||
# Get the conversations sorted (oldest first)
|
||||
@@ -295,22 +297,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
|
||||
while len(conversations) >= self.config.max_concurrent_conversations:
|
||||
oldest_conversation_id = conversations.pop().conversation_id
|
||||
logger.debug(
|
||||
f'closing_from_too_many_sessions:{user_id or ''}:{oldest_conversation_id}',
|
||||
extra={'session_id': oldest_conversation_id, 'user_id': user_id},
|
||||
)
|
||||
# Send status message to client and close session.
|
||||
status_update_dict = {
|
||||
'status_update': True,
|
||||
'type': 'error',
|
||||
'id': 'AGENT_ERROR$TOO_MANY_CONVERSATIONS',
|
||||
'message': 'Too many conversations at once. If you are still using this one, try reactivating it by prompting the agent to continue',
|
||||
}
|
||||
await self.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=ROOM_KEY.format(sid=oldest_conversation_id),
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
session = Session(
|
||||
@@ -395,8 +381,8 @@ class StandaloneConversationManager(ConversationManager):
|
||||
f'removing connections: {connection_ids_to_remove}',
|
||||
extra={'session_id': sid},
|
||||
)
|
||||
for connection_id in connection_ids_to_remove:
|
||||
self._local_connection_id_to_session_id.pop(connection_id, None)
|
||||
for connnnection_id in connection_ids_to_remove:
|
||||
self._local_connection_id_to_session_id.pop(connnnection_id, None)
|
||||
|
||||
session = self._local_agent_loops_by_sid.pop(sid, None)
|
||||
if not session:
|
||||
|
||||
@@ -9,6 +9,7 @@ from openhands.server.middleware import (
|
||||
CacheControlMiddleware,
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
ProviderTokenMiddleware,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from openhands.server.static import SPAStaticFiles
|
||||
@@ -31,5 +32,6 @@ base_app.add_middleware(
|
||||
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
|
||||
)
|
||||
base_app.middleware('http')(AttachConversationMiddleware(base_app))
|
||||
base_app.middleware('http')(ProviderTokenMiddleware(base_app))
|
||||
|
||||
app = socketio.ASGIApp(sio, other_asgi_app=base_app)
|
||||
|
||||
@@ -12,8 +12,8 @@ from starlette.requests import Request as StarletteRequest
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from openhands.server import shared
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.types import SessionMiddlewareInterface
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
class LocalhostCORSMiddleware(CORSMiddleware):
|
||||
@@ -147,10 +147,9 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
"""
|
||||
Attach the user's session based on the provided authentication token.
|
||||
"""
|
||||
user_id = await get_user_id(request)
|
||||
request.state.conversation = (
|
||||
await shared.conversation_manager.attach_to_conversation(
|
||||
request.state.sid, user_id
|
||||
request.state.sid, get_user_id(request)
|
||||
)
|
||||
)
|
||||
if not request.state.conversation:
|
||||
@@ -184,3 +183,27 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
await self._detach_session(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ProviderTokenMiddleware(SessionMiddlewareInterface):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
settings_store = await shared.SettingsStoreImpl.get_instance(
|
||||
shared.config, get_user_id(request)
|
||||
)
|
||||
settings = await settings_store.load()
|
||||
|
||||
# TODO: To avoid checks like this we should re-add the abilty to have completely different middleware in SAAS as in OSS
|
||||
if getattr(request.state, 'provider_tokens', None) is None:
|
||||
if (
|
||||
settings
|
||||
and settings.secrets_store
|
||||
and settings.secrets_store.provider_tokens
|
||||
):
|
||||
request.state.provider_tokens = settings.secrets_store.provider_tokens
|
||||
else:
|
||||
request.state.provider_tokens = None
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@@ -2,7 +2,6 @@ import os
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
@@ -22,10 +21,19 @@ from openhands.events.observation import (
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.auth import get_github_user_id, get_user_id
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
)
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
)
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
@@ -179,19 +187,24 @@ def zip_current_workspace(request: Request):
|
||||
|
||||
|
||||
@app.get('/git/changes')
|
||||
async def git_changes(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
async def git_changes(request: Request, conversation_id: str):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.info(f'Getting git changes in {runtime.git_dir}')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
logger.info(f'Getting git changes in {cwd}')
|
||||
|
||||
try:
|
||||
changes = await call_sync_from_async(runtime.get_git_changes)
|
||||
changes = await call_sync_from_async(runtime.get_git_changes, cwd)
|
||||
if changes is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
status_code=500,
|
||||
content={'error': 'Not a git repository'},
|
||||
)
|
||||
return changes
|
||||
@@ -210,16 +223,20 @@ async def git_changes(
|
||||
|
||||
|
||||
@app.get('/git/diff')
|
||||
async def git_diff(
|
||||
request: Request,
|
||||
path: str,
|
||||
conversation_id: str,
|
||||
):
|
||||
async def git_diff(request: Request, path: str, conversation_id: str):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.info(f'Getting git diff for {path} in {runtime.git_dir}')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
try:
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path)
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path, cwd)
|
||||
return diff
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(f'Error getting diff: {e}')
|
||||
@@ -227,3 +244,46 @@ async def git_diff(
|
||||
status_code=500,
|
||||
content={'error': f'Error getting diff: {e}'},
|
||||
)
|
||||
|
||||
|
||||
async def get_cwd(
|
||||
conversation_store: ConversationStore,
|
||||
conversation_id: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
):
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
conversation_info = await _get_conversation_info(metadata, is_running)
|
||||
|
||||
cwd = workspace_mount_path_in_sandbox
|
||||
if conversation_info and conversation_info.selected_repository:
|
||||
repo_dir = conversation_info.selected_repository.split('/')[-1]
|
||||
cwd = os.path.join(cwd, repo_dir)
|
||||
|
||||
return cwd
|
||||
|
||||
|
||||
async def _get_conversation_info(
|
||||
conversation: ConversationMetadata,
|
||||
is_running: bool,
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
title = conversation.title
|
||||
if not title:
|
||||
title = f'Conversation {conversation.conversation_id[:5]}'
|
||||
return ConversationInfo(
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=title,
|
||||
last_updated_at=conversation.last_updated_at,
|
||||
created_at=conversation.created_at,
|
||||
selected_repository=conversation.selected_repository,
|
||||
status=ConversationStatus.RUNNING
|
||||
if is_running
|
||||
else ConversationStatus.STOPPED,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error loading conversation {conversation.conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -15,12 +15,8 @@ from openhands.integrations.service_types import (
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.server.auth import get_access_token, get_provider_tokens, get_user_id
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
|
||||
app = APIRouter(prefix='/api/user')
|
||||
|
||||
@@ -143,9 +139,12 @@ async def get_suggested_tasks(
|
||||
- PRs owned by the user
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens, external_auth_token=access_token
|
||||
|
||||
if provider_tokens and ProviderType.GITHUB in provider_tokens:
|
||||
token = provider_tokens[ProviderType.GITHUB]
|
||||
|
||||
client = GithubServiceImpl(
|
||||
user_id=token.user_id, external_auth_token=access_token, token=token.token
|
||||
)
|
||||
try:
|
||||
tasks: list[SuggestedTask] = await client.get_suggested_tasks()
|
||||
@@ -154,16 +153,16 @@ async def get_suggested_tasks(
|
||||
except AuthenticationError as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
except UnknownException as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content='No providers set.',
|
||||
content='GitHub token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, status
|
||||
from fastapi import APIRouter, Body, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -15,6 +15,11 @@ from openhands.integrations.provider import (
|
||||
)
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.server.auth import (
|
||||
get_github_user_id,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
@@ -28,12 +33,6 @@ from openhands.server.shared import (
|
||||
file_store,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
@@ -96,7 +95,7 @@ async def _create_new_conversation(
|
||||
session_init_args['selected_branch'] = selected_branch
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
logger.info('Loading conversation store')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id, None)
|
||||
logger.info('Conversation store loaded')
|
||||
|
||||
conversation_id = uuid.uuid4().hex
|
||||
@@ -153,17 +152,14 @@ async def _create_new_conversation(
|
||||
|
||||
|
||||
@app.post('/conversations')
|
||||
async def new_conversation(
|
||||
data: InitSessionRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
):
|
||||
async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
"""Initialize a new session or join an existing one.
|
||||
|
||||
After successful initialization, the client should connect to the WebSocket
|
||||
using the returned conversation ID.
|
||||
"""
|
||||
logger.info('Initializing new conversation')
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
selected_repository = data.selected_repository
|
||||
selected_branch = data.selected_branch
|
||||
initial_user_msg = data.initial_user_msg
|
||||
@@ -173,7 +169,7 @@ async def new_conversation(
|
||||
try:
|
||||
# Create conversation with initial message
|
||||
conversation_id = await _create_new_conversation(
|
||||
user_id,
|
||||
get_user_id(request),
|
||||
provider_tokens,
|
||||
selected_repository,
|
||||
selected_branch,
|
||||
@@ -208,11 +204,13 @@ async def new_conversation(
|
||||
|
||||
@app.get('/conversations')
|
||||
async def search_conversations(
|
||||
request: Request,
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> ConversationInfoResultSet:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Filter out conversations older than max_age
|
||||
@@ -230,7 +228,7 @@ async def search_conversations(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
)
|
||||
running_conversations = await conversation_manager.get_running_agent_loops(
|
||||
user_id, set(conversation_ids)
|
||||
get_user_id(request), set(conversation_ids)
|
||||
)
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
@@ -247,9 +245,11 @@ async def search_conversations(
|
||||
|
||||
@app.get('/conversations/{conversation_id}')
|
||||
async def get_conversation(
|
||||
conversation_id: str,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
conversation_id: str, request: Request
|
||||
) -> ConversationInfo | None:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
try:
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
@@ -340,12 +340,11 @@ async def auto_generate_title(conversation_id: str, user_id: str | None) -> str:
|
||||
|
||||
@app.patch('/conversations/{conversation_id}')
|
||||
async def update_conversation(
|
||||
conversation_id: str,
|
||||
title: str = Body(embed=True),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
request: Request, conversation_id: str, title: str = Body(embed=True)
|
||||
) -> bool:
|
||||
user_id = get_user_id(request)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
config, user_id, get_github_user_id(request)
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if not metadata:
|
||||
@@ -367,10 +366,10 @@ async def update_conversation(
|
||||
@app.delete('/conversations/{conversation_id}')
|
||||
async def delete_conversation(
|
||||
conversation_id: str,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
request: Request,
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
SecretStore,
|
||||
)
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.server.auth import get_provider_tokens, get_user_id
|
||||
from openhands.server.settings import (
|
||||
GETSettingsCustomSecrets,
|
||||
GETSettingsModel,
|
||||
@@ -19,24 +15,16 @@ from openhands.server.settings import (
|
||||
)
|
||||
from openhands.server.shared import SettingsStoreImpl, config, server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
@app.get('/settings', response_model=GETSettingsModel)
|
||||
async def load_settings(
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings: Settings | None = Depends(get_user_settings),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
|
||||
try:
|
||||
user_id = get_user_id(request)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -48,6 +36,7 @@ async def load_settings(
|
||||
if bool(user_id):
|
||||
provider_tokens_set[ProviderType.GITHUB.value] = True
|
||||
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
if provider_tokens:
|
||||
all_provider_types = [provider.value for provider in ProviderType]
|
||||
provider_tokens_types = [provider.value for provider in provider_tokens]
|
||||
@@ -59,7 +48,7 @@ async def load_settings(
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude='secrets_store'),
|
||||
llm_api_key_set=settings.llm_api_key is not None and bool(settings.llm_api_key),
|
||||
llm_api_key_set=settings.llm_api_key is not None,
|
||||
provider_tokens_set=provider_tokens_set,
|
||||
)
|
||||
settings_with_token_data.llm_api_key = None
|
||||
@@ -74,9 +63,12 @@ async def load_settings(
|
||||
|
||||
@app.get('/secrets', response_model=GETSettingsCustomSecrets)
|
||||
async def load_custom_secrets_names(
|
||||
settings: Settings | None = Depends(get_user_settings),
|
||||
request: Request,
|
||||
) -> GETSettingsCustomSecrets | JSONResponse:
|
||||
try:
|
||||
user_id = get_user_id(request)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -101,11 +93,13 @@ async def load_custom_secrets_names(
|
||||
|
||||
@app.post('/secrets', response_model=dict[str, str])
|
||||
async def add_custom_secret(
|
||||
incoming_secrets: POSTSettingsCustomSecrets,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
request: Request, incoming_secrets: POSTSettingsCustomSecrets
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
existing_settings = await settings_store.load()
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings: Settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
for (
|
||||
secret_name,
|
||||
@@ -127,6 +121,7 @@ async def add_custom_secret(
|
||||
update={'secrets_store': updated_secret_store}
|
||||
)
|
||||
|
||||
updated_settings = convert_to_settings(updated_settings)
|
||||
await settings_store.store(updated_settings)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -142,11 +137,11 @@ async def add_custom_secret(
|
||||
|
||||
|
||||
@app.delete('/secrets/{secret_id}')
|
||||
async def delete_custom_secret(
|
||||
secret_id: str,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
async def delete_custom_secret(request: Request, secret_id: str) -> JSONResponse:
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings: Settings | None = await settings_store.load()
|
||||
custom_secrets = {}
|
||||
if existing_settings:
|
||||
@@ -167,6 +162,7 @@ async def delete_custom_secret(
|
||||
update={'secrets_store': updated_secret_store}
|
||||
)
|
||||
|
||||
updated_settings = convert_to_settings(updated_settings)
|
||||
await settings_store.store(updated_settings)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -182,10 +178,12 @@ async def delete_custom_secret(
|
||||
|
||||
|
||||
@app.post('/unset-settings-tokens', response_model=dict[str, str])
|
||||
async def unset_settings_tokens(
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
async def unset_settings_tokens(request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
settings = existing_settings.model_copy(
|
||||
@@ -207,20 +205,58 @@ async def unset_settings_tokens(
|
||||
|
||||
|
||||
@app.post('/reset-settings', response_model=dict[str, str])
|
||||
async def reset_settings() -> JSONResponse:
|
||||
async def reset_settings(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Resets user settings. (Deprecated)
|
||||
Resets user settings.
|
||||
"""
|
||||
logger.warning(
|
||||
f"Deprecated endpoint /api/reset-settings called by user"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
content={'error': 'Reset settings functionality has been removed.'},
|
||||
)
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
|
||||
existing_settings = await settings_store.load()
|
||||
settings = Settings(
|
||||
language='en',
|
||||
agent='CodeActAgent',
|
||||
security_analyzer='',
|
||||
confirmation_mode=False,
|
||||
llm_model='anthropic/claude-3-5-sonnet-20241022',
|
||||
llm_api_key='',
|
||||
llm_base_url='',
|
||||
remote_runtime_resource_factor=1,
|
||||
enable_default_condenser=True,
|
||||
enable_sound_notifications=False,
|
||||
user_consents_to_analytics=existing_settings.user_consents_to_analytics
|
||||
if existing_settings
|
||||
else False,
|
||||
)
|
||||
|
||||
server_config_values = server_config.get_config()
|
||||
is_hide_llm_settings_enabled = server_config_values.get(
|
||||
'FEATURE_FLAGS', {}
|
||||
).get('HIDE_LLM_SETTINGS', False)
|
||||
# We don't want the user to be able to modify these settings in SaaS
|
||||
if server_config.app_mode == AppMode.SAAS and is_hide_llm_settings_enabled:
|
||||
if existing_settings:
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
settings.llm_base_url = existing_settings.llm_base_url
|
||||
settings.llm_model = existing_settings.llm_model
|
||||
|
||||
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 resetting settings: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong resetting settings'},
|
||||
)
|
||||
|
||||
|
||||
async def check_provider_tokens(settings: POSTSettingsModel) -> str:
|
||||
async def check_provider_tokens(request: Request, settings: POSTSettingsModel) -> str:
|
||||
if settings.provider_tokens:
|
||||
# Remove extraneous token types
|
||||
provider_types = [provider.value for provider in ProviderType]
|
||||
@@ -240,9 +276,8 @@ async def check_provider_tokens(settings: POSTSettingsModel) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
async def store_provider_tokens(
|
||||
settings: POSTSettingsModel, settings_store: SettingsStore
|
||||
):
|
||||
async def store_provider_tokens(request: Request, settings: POSTSettingsModel):
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, get_user_id(request))
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
if settings.provider_tokens:
|
||||
@@ -276,8 +311,9 @@ async def store_provider_tokens(
|
||||
|
||||
|
||||
async def store_llm_settings(
|
||||
settings: POSTSettingsModel, settings_store: SettingsStore
|
||||
request: Request, settings: POSTSettingsModel
|
||||
) -> POSTSettingsModel:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, get_user_id(request))
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
@@ -295,11 +331,11 @@ async def store_llm_settings(
|
||||
|
||||
@app.post('/settings', response_model=dict[str, str])
|
||||
async def store_settings(
|
||||
request: Request,
|
||||
settings: POSTSettingsModel,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
# Check provider tokens are valid
|
||||
provider_err_msg = await check_provider_tokens(settings)
|
||||
provider_err_msg = await check_provider_tokens(request, settings)
|
||||
if provider_err_msg:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -307,11 +343,14 @@ async def store_settings(
|
||||
)
|
||||
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(settings, settings_store)
|
||||
settings = await store_llm_settings(request, settings)
|
||||
|
||||
# Keep existing analytics consent if not provided
|
||||
if settings.user_consents_to_analytics is None:
|
||||
@@ -319,7 +358,7 @@ async def store_settings(
|
||||
existing_settings.user_consents_to_analytics
|
||||
)
|
||||
|
||||
settings = await store_provider_tokens(settings, settings_store)
|
||||
settings = await store_provider_tokens(request, settings)
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
@@ -35,6 +37,7 @@ class Settings(BaseModel):
|
||||
user_consents_to_analytics: bool | None = None
|
||||
sandbox_base_container_image: str | None = None
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
accepted_tos: datetime | None = None
|
||||
|
||||
model_config = {
|
||||
'validate_assignment': True,
|
||||
@@ -94,10 +97,7 @@ class Settings(BaseModel):
|
||||
return {
|
||||
'provider_tokens': secrets.provider_tokens_serializer(
|
||||
secrets.provider_tokens, info
|
||||
),
|
||||
'custom_secrets': secrets.custom_secrets_serializer(
|
||||
secrets.custom_secrets, info
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from fastapi import Request
|
||||
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.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
async def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
return provider_tokens
|
||||
|
||||
|
||||
async def get_access_token(request: Request) -> SecretStr | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
access_token = await user_auth.get_access_token()
|
||||
return access_token
|
||||
|
||||
|
||||
async def get_user_id(request: Request) -> str | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
return user_id
|
||||
|
||||
|
||||
async def get_github_user_id(request: Request) -> str | None:
|
||||
provider_tokens = await get_provider_tokens(request)
|
||||
if not provider_tokens:
|
||||
return None
|
||||
github_provider = provider_tokens.get(ProviderType.GITHUB)
|
||||
if github_provider:
|
||||
return github_provider.user_id
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_settings(request: Request) -> Settings | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
user_settings = await user_auth.get_user_settings()
|
||||
return user_settings
|
||||
|
||||
|
||||
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
|
||||
@@ -1,57 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
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.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultUserAuth(UserAuth):
|
||||
"""Default user authentication mechanism"""
|
||||
|
||||
_settings: Settings | None = None
|
||||
_settings_store: SettingsStore | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""The default implementation does not support multi tenancy, so user_id is always None"""
|
||||
return None
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""The default implementation does not support multi tenancy, so access_token is always None"""
|
||||
return None
|
||||
|
||||
async def get_user_settings_store(self):
|
||||
settings_store = self._settings_store
|
||||
if settings_store:
|
||||
return settings_store
|
||||
user_id = await self.get_user_id()
|
||||
settings_store = await shared.SettingsStoreImpl.get_instance(
|
||||
shared.config, user_id
|
||||
)
|
||||
self._settings_store = settings_store
|
||||
return settings_store
|
||||
|
||||
async def get_user_settings(self) -> Settings | None:
|
||||
settings = self._settings
|
||||
if settings:
|
||||
return settings
|
||||
settings_store = await self.get_user_settings_store()
|
||||
settings = await settings_store.load()
|
||||
self._settings = settings
|
||||
return settings
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
settings = await self.get_user_settings()
|
||||
secrets_store = getattr(settings, 'secrets_store', None)
|
||||
provider_tokens = getattr(secrets_store, 'provider_tokens', None)
|
||||
return provider_tokens
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
user_auth = DefaultUserAuth()
|
||||
return user_auth
|
||||
@@ -1,63 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fastapi import Request
|
||||
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.settings.settings_store import SettingsStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class UserAuth(ABC):
|
||||
"""Extensible class encapsulating user Authentication"""
|
||||
|
||||
_settings: Settings | None
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""Get the unique identifier for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""Get the access token for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
"""Get the provider tokens for the current user."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_settings_store(self) -> SettingsStore | None:
|
||||
"""Get the settings store for the current user."""
|
||||
|
||||
async def get_user_settings(self) -> Settings | None:
|
||||
"""Get the user settings for the current user"""
|
||||
settings = self._settings
|
||||
if settings:
|
||||
return settings
|
||||
settings_store = await self.get_user_settings_store()
|
||||
if settings_store is None:
|
||||
return None
|
||||
settings = await settings_store.load()
|
||||
self._settings = settings
|
||||
return settings
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
"""Get an instance of UserAuth from the request given"""
|
||||
|
||||
|
||||
async def get_user_auth(request: Request) -> UserAuth:
|
||||
user_auth = getattr(request.state, 'user_auth', None)
|
||||
if user_auth:
|
||||
return user_auth
|
||||
impl_name = server_config.user_auth_class
|
||||
impl = get_impl(UserAuth, impl_name)
|
||||
user_auth = await impl.get_instance(request)
|
||||
request.state.user_auth = user_auth
|
||||
return user_auth
|
||||
@@ -1,16 +0,0 @@
|
||||
from fastapi import Request
|
||||
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
from openhands.server.user_auth import get_user_auth
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
|
||||
|
||||
async def get_conversation_store(request: Request) -> ConversationStore | None:
|
||||
conversation_store = getattr(request.state, 'conversation_store', None)
|
||||
if conversation_store:
|
||||
return conversation_store
|
||||
user_auth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
request.state.conversation_store = conversation_store
|
||||
return conversation_store
|
||||
@@ -60,6 +60,6 @@ class ConversationStore(ABC):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: str | None
|
||||
cls, config: AppConfig, user_id: str | None, github_user_id: str | None
|
||||
) -> ConversationStore:
|
||||
"""Get a store for the user represented by the token given."""
|
||||
|
||||
@@ -101,7 +101,7 @@ class FileConversationStore(ConversationStore):
|
||||
|
||||
@classmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: str | None
|
||||
cls, config: AppConfig, user_id: str | None, github_user_id: str | None
|
||||
) -> FileConversationStore:
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
return FileConversationStore(file_store)
|
||||
|
||||
1117
poetry.lock
generated
1117
poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -71,14 +71,14 @@ stripe = ">=11.5,<13.0"
|
||||
ipywidgets = "^8.1.5"
|
||||
qtconsole = "^5.6.1"
|
||||
memory-profiler = "^0.61.0"
|
||||
daytona-sdk = "0.15.0"
|
||||
daytona-sdk = "0.14.0"
|
||||
mcp = "1.6.0"
|
||||
python-json-logger = "^3.2.1"
|
||||
playwright = "^1.51.0"
|
||||
prompt-toolkit = "^3.0.50"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.11.7"
|
||||
ruff = "0.11.6"
|
||||
mypy = "1.15.0"
|
||||
pre-commit = "4.2.0"
|
||||
build = "*"
|
||||
|
||||
@@ -13,13 +13,12 @@ from openhands.resolver.send_pull_request import (
|
||||
apply_patch,
|
||||
initialize_repo,
|
||||
load_single_resolver_output,
|
||||
main,
|
||||
make_commit,
|
||||
process_single_issue,
|
||||
send_pull_request,
|
||||
update_existing_pull_request,
|
||||
)
|
||||
|
||||
from openhands.resolver.send_pull_request import main
|
||||
|
||||
@pytest.fixture
|
||||
def mock_output_dir():
|
||||
@@ -1105,6 +1104,7 @@ def test_main(
|
||||
mock_process_single_issue,
|
||||
mock_parser,
|
||||
):
|
||||
|
||||
# Setup mock parser
|
||||
mock_args = MagicMock()
|
||||
mock_args.token = None
|
||||
@@ -1171,6 +1171,7 @@ def test_main(
|
||||
mock_path_exists.assert_called_with('/mock/output')
|
||||
mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)
|
||||
|
||||
|
||||
# Test for invalid issue number
|
||||
mock_args.issue_number = 'invalid'
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -14,12 +14,12 @@ from openhands.resolver.send_pull_request import (
|
||||
apply_patch,
|
||||
initialize_repo,
|
||||
load_single_resolver_output,
|
||||
main,
|
||||
make_commit,
|
||||
process_single_issue,
|
||||
send_pull_request,
|
||||
update_existing_pull_request,
|
||||
)
|
||||
from openhands.resolver.send_pull_request import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1007,6 +1007,7 @@ def test_main(
|
||||
mock_process_single_issue,
|
||||
mock_parser,
|
||||
):
|
||||
|
||||
# Setup mock parser
|
||||
mock_args = MagicMock()
|
||||
mock_args.token = None
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
@@ -15,7 +16,6 @@ from openhands.server.routes.manage_conversations import (
|
||||
search_conversations,
|
||||
update_conversation,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.storage.locations import get_conversation_metadata_filename
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
@@ -72,36 +72,9 @@ async def test_search_conversations():
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
user_id='12345',
|
||||
conversation_store=mock_store,
|
||||
MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
|
||||
expected = ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationInfo(
|
||||
@@ -124,51 +97,26 @@ async def test_search_conversations():
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation():
|
||||
with _patch_store():
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_metadata = AsyncMock(
|
||||
return_value=ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
|
||||
# Mock the conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
|
||||
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', conversation_store=mock_store
|
||||
)
|
||||
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
assert conversation == expected
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
assert conversation == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_conversation():
|
||||
with _patch_store():
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_metadata = AsyncMock(side_effect=FileNotFoundError)
|
||||
|
||||
assert (
|
||||
await get_conversation(
|
||||
'no_such_conversation', conversation_store=mock_store
|
||||
'no_such_conversation', MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -177,102 +125,34 @@ async def test_get_missing_conversation():
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_conversation():
|
||||
with _patch_store():
|
||||
# Mock the ConversationStoreImpl.get_instance
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
|
||||
) as mock_get_instance:
|
||||
# Create a mock conversation store
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Mock metadata
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
|
||||
# Set up the mock to return metadata and then save it
|
||||
mock_store.get_metadata = AsyncMock(return_value=metadata)
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
|
||||
# Return the mock store from get_instance
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Call update_conversation
|
||||
result = await update_conversation(
|
||||
'some_conversation_id',
|
||||
'New Title',
|
||||
user_id='12345',
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify that save_metadata was called with updated metadata
|
||||
mock_store.save_metadata.assert_called_once()
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.title == 'New Title'
|
||||
await update_conversation(
|
||||
MagicMock(state=MagicMock(github_token='')),
|
||||
'some_conversation_id',
|
||||
'New Title',
|
||||
)
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='New Title',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
assert conversation == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_conversation():
|
||||
with _patch_store():
|
||||
# Mock the ConversationStoreImpl.get_instance
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
|
||||
) as mock_get_instance:
|
||||
# Create a mock conversation store
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Set up the mock to return metadata and then delete it
|
||||
mock_store.get_metadata = AsyncMock(
|
||||
return_value=ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
with patch.object(DockerRuntime, 'delete', return_value=None):
|
||||
await delete_conversation(
|
||||
'some_conversation_id',
|
||||
MagicMock(state=MagicMock(github_token='')),
|
||||
)
|
||||
mock_store.delete_metadata = AsyncMock()
|
||||
|
||||
# Return the mock store from get_instance
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Mock the conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
|
||||
|
||||
# Mock the runtime class
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.get_runtime_cls'
|
||||
) as mock_get_runtime_cls:
|
||||
mock_runtime_cls = MagicMock()
|
||||
mock_runtime_cls.delete = AsyncMock()
|
||||
mock_get_runtime_cls.return_value = mock_runtime_cls
|
||||
|
||||
# Call delete_conversation
|
||||
result = await delete_conversation(
|
||||
'some_conversation_id', user_id='12345'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify that delete_metadata was called
|
||||
mock_store.delete_metadata.assert_called_once_with(
|
||||
'some_conversation_id'
|
||||
)
|
||||
|
||||
# Verify that runtime.delete was called
|
||||
mock_runtime_cls.delete.assert_called_once_with(
|
||||
'some_conversation_id'
|
||||
)
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
assert conversation is None
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
# Mark all test methods as asyncio tests
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestGitHandler(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create temporary directories for our test repositories
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.origin_dir = os.path.join(self.test_dir, 'origin')
|
||||
self.local_dir = os.path.join(self.test_dir, 'local')
|
||||
|
||||
# Create the directories
|
||||
os.makedirs(self.origin_dir, exist_ok=True)
|
||||
os.makedirs(self.local_dir, exist_ok=True)
|
||||
|
||||
# Track executed commands for verification
|
||||
self.executed_commands = []
|
||||
|
||||
# Initialize the GitHandler with our real execute function
|
||||
self.git_handler = GitHandler(self._execute_command)
|
||||
self.git_handler.set_cwd(self.local_dir)
|
||||
|
||||
# Set up the git repositories
|
||||
self._setup_git_repos()
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up the temporary directories
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def _execute_command(self, cmd, cwd=None):
|
||||
"""Execute a shell command and return the result."""
|
||||
self.executed_commands.append((cmd, cwd))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
|
||||
)
|
||||
return CommandResult(result.stdout, result.returncode)
|
||||
except Exception as e:
|
||||
return CommandResult(str(e), 1)
|
||||
|
||||
def _setup_git_repos(self):
|
||||
"""Set up real git repositories for testing."""
|
||||
# Set up origin repository
|
||||
self._execute_command('git init --initial-branch=main', self.origin_dir)
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.origin_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.origin_dir)
|
||||
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Original content')
|
||||
|
||||
self._execute_command('git add file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
|
||||
|
||||
# Clone the origin repository to local
|
||||
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.local_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
||||
|
||||
# Create a feature branch in the local repository
|
||||
self._execute_command('git checkout -b feature-branch', self.local_dir)
|
||||
|
||||
# Modify a file and create a new file
|
||||
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Modified content')
|
||||
|
||||
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
|
||||
# Add and commit file1.txt changes to create a baseline
|
||||
self._execute_command('git add file1.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
|
||||
|
||||
# Add and commit file2.txt, then modify it
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add file2.txt'", self.local_dir)
|
||||
|
||||
# Modify file2.txt and stage it
|
||||
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
|
||||
f.write('Modified new file content')
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
|
||||
# Create a file that will be deleted
|
||||
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
|
||||
f.write('File to be deleted')
|
||||
|
||||
self._execute_command('git add file3.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add file3.txt'", self.local_dir)
|
||||
self._execute_command('git rm file3.txt', self.local_dir)
|
||||
|
||||
# Modify file1.txt again but don't stage it (unstaged change)
|
||||
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Modified content again')
|
||||
|
||||
# Push the feature branch to origin
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
async def test_is_git_repo(self):
|
||||
"""Test that _is_git_repo returns True for a git repository."""
|
||||
self.assertTrue(await self.git_handler._is_git_repo())
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git rev-parse --is-inside-work-tree'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_default_branch(self):
|
||||
"""Test that _get_default_branch returns the correct branch name."""
|
||||
branch = await self.git_handler._get_default_branch()
|
||||
self.assertEqual(branch, 'main')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git remote show origin | grep "HEAD branch"'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_current_branch(self):
|
||||
"""Test that _get_current_branch returns the correct branch name."""
|
||||
branch = await self.git_handler._get_current_branch()
|
||||
self.assertEqual(branch, 'feature-branch')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git rev-parse --abbrev-ref HEAD'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_valid_ref_with_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref returns the current branch in origin when it exists."""
|
||||
# This test uses the setup from setUp where the current branch exists in origin
|
||||
ref = await self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git rev-parse --verify')
|
||||
]
|
||||
|
||||
# First should check origin/feature-branch (current branch)
|
||||
self.assertTrue(any('origin/feature-branch' in cmd for cmd in verify_commands))
|
||||
|
||||
# Should have found a valid ref (origin/feature-branch)
|
||||
self.assertEqual(ref, 'origin/feature-branch')
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_valid_ref_without_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
|
||||
# Create a new branch that doesn't exist in origin
|
||||
self._execute_command('git checkout -b new-local-branch', self.local_dir)
|
||||
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = await self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git rev-parse --verify')
|
||||
]
|
||||
|
||||
# Should have tried origin/new-local-branch first (which doesn't exist)
|
||||
self.assertTrue(
|
||||
any('origin/new-local-branch' in cmd for cmd in verify_commands)
|
||||
)
|
||||
|
||||
# Should have found a valid ref (origin/main or merge-base)
|
||||
self.assertNotEqual(ref, 'origin/new-local-branch')
|
||||
self.assertTrue(ref == 'origin/main' or 'merge-base' in ref)
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_valid_ref_without_origin(self):
|
||||
"""Test that _get_valid_ref falls back to empty tree ref when there's no origin."""
|
||||
# Create a new directory with a git repo but no origin
|
||||
no_origin_dir = os.path.join(self.test_dir, 'no-origin')
|
||||
os.makedirs(no_origin_dir, exist_ok=True)
|
||||
|
||||
# Initialize git repo without origin
|
||||
self._execute_command('git init', no_origin_dir)
|
||||
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
|
||||
self._execute_command("git config user.name 'Test User'", no_origin_dir)
|
||||
|
||||
# Create a file and commit it using subprocess
|
||||
file_path = os.path.join(no_origin_dir, 'file1.txt')
|
||||
self._execute_command(
|
||||
f'echo "Content in repo without origin" > {file_path}', no_origin_dir
|
||||
)
|
||||
self._execute_command('git add file1.txt', no_origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
|
||||
|
||||
# Create a custom GitHandler with a modified _get_default_branch method for this test
|
||||
class TestGitHandler(GitHandler):
|
||||
async def _get_default_branch(self) -> str:
|
||||
# Override to handle repos without origin
|
||||
try:
|
||||
return await super()._get_default_branch()
|
||||
except IndexError:
|
||||
return 'main' # Default fallback
|
||||
|
||||
# Create a new GitHandler for this repo
|
||||
no_origin_handler = TestGitHandler(self._execute_command)
|
||||
no_origin_handler.set_cwd(no_origin_dir)
|
||||
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = await no_origin_handler._get_valid_ref()
|
||||
|
||||
# Verify that git commands were executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd.startswith('git rev-parse --verify')
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
# Should have fallen back to the empty tree ref
|
||||
self.assertEqual(
|
||||
ref, '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
|
||||
)
|
||||
|
||||
# Verify the ref exists (the empty tree ref always exists)
|
||||
result = self._execute_command(
|
||||
'git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
|
||||
no_origin_dir,
|
||||
)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_ref_content(self):
|
||||
"""Test that _get_ref_content returns the content from a valid ref."""
|
||||
content = await self.git_handler._get_ref_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content')
|
||||
|
||||
# Should have called _get_valid_ref and then git show
|
||||
show_commands = [
|
||||
cmd for cmd, _ in self.executed_commands if cmd.startswith('git show')
|
||||
]
|
||||
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
|
||||
|
||||
async def test_get_current_file_content(self):
|
||||
"""Test that _get_current_file_content returns the current content of a file."""
|
||||
content = await self.git_handler._get_current_file_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content again')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(cmd == 'cat file1.txt' for cmd, _ in self.executed_commands)
|
||||
)
|
||||
|
||||
async def test_get_changed_files(self):
|
||||
"""Test that _get_changed_files returns the list of changed files."""
|
||||
# Let's create a new file to ensure it shows up in the diff
|
||||
# Use subprocess directly to create and add the file
|
||||
file_path = os.path.join(self.local_dir, 'new_file.txt')
|
||||
self._execute_command(f'echo "New file content" > {file_path}', self.local_dir)
|
||||
self._execute_command('git add new_file.txt', self.local_dir)
|
||||
|
||||
files = await self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
|
||||
# Should include file1.txt (modified) and file3.txt (deleted)
|
||||
file_paths = [line.split('\t')[-1] for line in files if '\t' in line]
|
||||
self.assertIn('file1.txt', file_paths)
|
||||
self.assertIn('file3.txt', file_paths)
|
||||
# Also check for the new file
|
||||
self.assertIn('new_file.txt', file_paths)
|
||||
|
||||
# Should have called _get_valid_ref and then git diff
|
||||
diff_commands = [
|
||||
cmd for cmd, _ in self.executed_commands if cmd.startswith('git diff')
|
||||
]
|
||||
self.assertTrue(diff_commands)
|
||||
|
||||
async def test_get_untracked_files(self):
|
||||
"""Test that _get_untracked_files returns the list of untracked files."""
|
||||
# Create an untracked file using subprocess
|
||||
file_path = os.path.join(self.local_dir, 'untracked.txt')
|
||||
self._execute_command(
|
||||
f'echo "Untracked file content" > {file_path}', self.local_dir
|
||||
)
|
||||
|
||||
files = await self.git_handler._get_untracked_files()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0]['path'], 'untracked.txt')
|
||||
self.assertEqual(files[0]['status'], 'A')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git ls-files --others --exclude-standard'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_git_changes(self):
|
||||
"""Test that get_git_changes returns the combined list of changed and untracked files."""
|
||||
# Create an untracked file using subprocess
|
||||
file_path = os.path.join(self.local_dir, 'untracked.txt')
|
||||
self._execute_command(
|
||||
f'echo "Untracked file content" > {file_path}', self.local_dir
|
||||
)
|
||||
|
||||
# Create a new file and stage it
|
||||
file_path2 = os.path.join(self.local_dir, 'new_file2.txt')
|
||||
self._execute_command(
|
||||
f'echo "New file 2 content" > {file_path2}', self.local_dir
|
||||
)
|
||||
self._execute_command('git add new_file2.txt', self.local_dir)
|
||||
|
||||
changes = await self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should include file1.txt (modified), file3.txt (deleted), new_file2.txt (added), and untracked.txt (untracked)
|
||||
paths = [change['path'] for change in changes]
|
||||
self.assertIn('file1.txt', paths)
|
||||
self.assertIn('file3.txt', paths)
|
||||
self.assertIn('new_file2.txt', paths)
|
||||
self.assertIn('untracked.txt', paths)
|
||||
|
||||
# Check that the changes include both changed and untracked files
|
||||
statuses = [change['status'] for change in changes]
|
||||
self.assertIn('M', statuses) # Modified
|
||||
self.assertIn('A', statuses) # Added
|
||||
self.assertIn('D', statuses) # Deleted
|
||||
|
||||
async def test_get_git_diff(self):
|
||||
"""Test that get_git_diff returns the original and modified content of a file."""
|
||||
diff = await self.git_handler.get_git_diff('file1.txt')
|
||||
self.assertEqual(diff['modified'].strip(), 'Modified content again')
|
||||
self.assertEqual(diff['original'].strip(), 'Modified content')
|
||||
|
||||
# Should have called _get_current_file_content and _get_ref_content
|
||||
self.assertTrue(
|
||||
any('cat file1.txt' in cmd for cmd, _ in self.executed_commands)
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
'git show' in cmd and 'file1.txt' in cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
asyncio.run(unittest.main())
|
||||
@@ -1,167 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
|
||||
class TestGitHandlerWithRealRepo(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create temporary directories for our test repositories
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.origin_dir = os.path.join(self.test_dir, 'origin')
|
||||
self.local_dir = os.path.join(self.test_dir, 'local')
|
||||
|
||||
# Create the directories
|
||||
os.makedirs(self.origin_dir, exist_ok=True)
|
||||
os.makedirs(self.local_dir, exist_ok=True)
|
||||
|
||||
# Set up the git repositories
|
||||
self._setup_git_repos()
|
||||
|
||||
# Initialize the GitHandler with a real execute function
|
||||
self.git_handler = GitHandler(self._execute_command)
|
||||
self.git_handler.set_cwd(self.local_dir)
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up the temporary directories
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def _execute_command(self, cmd, cwd=None):
|
||||
"""Execute a shell command and return the result."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
|
||||
)
|
||||
return CommandResult(result.stdout, result.returncode)
|
||||
except Exception as e:
|
||||
return CommandResult(str(e), 1)
|
||||
|
||||
def _setup_git_repos(self):
|
||||
"""Set up real git repositories for testing."""
|
||||
# Set up origin repository
|
||||
self._execute_command('git init --initial-branch=main', self.origin_dir)
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.origin_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.origin_dir)
|
||||
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Original content')
|
||||
|
||||
self._execute_command('git add file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
|
||||
|
||||
# Clone the origin repository to local
|
||||
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.local_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
||||
|
||||
# Create a feature branch in the local repository
|
||||
self._execute_command('git checkout -b feature-branch', self.local_dir)
|
||||
|
||||
# Modify a file and create a new file
|
||||
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Modified content')
|
||||
|
||||
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
|
||||
# Add the new file but don't commit anything yet
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
|
||||
def test_is_git_repo(self):
|
||||
"""Test that _is_git_repo returns True for a git repository."""
|
||||
self.assertTrue(self.git_handler._is_git_repo())
|
||||
|
||||
def test_get_default_branch(self):
|
||||
"""Test that _get_default_branch returns the correct branch name."""
|
||||
branch = self.git_handler._get_default_branch()
|
||||
self.assertEqual(branch, 'main')
|
||||
|
||||
def test_get_current_branch(self):
|
||||
"""Test that _get_current_branch returns the correct branch name."""
|
||||
branch = self.git_handler._get_current_branch()
|
||||
self.assertEqual(branch, 'feature-branch')
|
||||
|
||||
def test_get_valid_ref(self):
|
||||
"""Test that _get_valid_ref returns a valid ref."""
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Push the feature branch to origin to test the highest priority ref
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
# Get the valid ref again, should be origin/feature-branch now
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_get_ref_content(self):
|
||||
"""Test that _get_ref_content returns the content from a valid ref."""
|
||||
# First commit the changes to make sure we have a valid ref
|
||||
self._execute_command('git add file1.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
|
||||
|
||||
# Get the content of file1.txt from the main branch
|
||||
content = self.git_handler._get_ref_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Original content')
|
||||
|
||||
def test_get_current_file_content(self):
|
||||
"""Test that _get_current_file_content returns the current content of a file."""
|
||||
content = self.git_handler._get_current_file_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content')
|
||||
|
||||
def test_get_changed_files(self):
|
||||
"""Test that _get_changed_files returns the list of changed files."""
|
||||
files = self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
|
||||
# Should include file1.txt (modified) and file2.txt (added)
|
||||
file_paths = [line.split('\t')[-1] for line in files]
|
||||
self.assertIn('file1.txt', file_paths)
|
||||
self.assertIn('file2.txt', file_paths)
|
||||
|
||||
def test_get_untracked_files(self):
|
||||
"""Test that _get_untracked_files returns the list of untracked files."""
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
files = self.git_handler._get_untracked_files()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0]['path'], 'untracked.txt')
|
||||
self.assertEqual(files[0]['status'], 'A')
|
||||
|
||||
def test_get_git_changes(self):
|
||||
"""Test that get_git_changes returns the combined list of changed and untracked files."""
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should include file1.txt (modified), file2.txt (added), and untracked.txt (untracked)
|
||||
paths = [change['path'] for change in changes]
|
||||
self.assertIn('file1.txt', paths)
|
||||
self.assertIn('file2.txt', paths)
|
||||
self.assertIn('untracked.txt', paths)
|
||||
|
||||
def test_get_git_diff(self):
|
||||
"""Test that get_git_diff returns the original and modified content of a file."""
|
||||
diff = self.git_handler.get_git_diff('file1.txt')
|
||||
self.assertEqual(diff['modified'].strip(), 'Modified content')
|
||||
self.assertEqual(diff['original'].strip(), 'Original content')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for the custom secrets API endpoints."""
|
||||
# flake8: noqa: E501
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
@@ -12,8 +10,6 @@ from pydantic import SecretStr
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
|
||||
from openhands.server.routes.settings import app as settings_app
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -24,128 +20,127 @@ def test_client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_file_settings_store():
|
||||
store = FileSettingsStore(InMemoryFileStore())
|
||||
with patch(
|
||||
'openhands.storage.settings.file_settings_store.FileSettingsStore.get_instance',
|
||||
AsyncMock(return_value=store),
|
||||
):
|
||||
yield store
|
||||
@pytest.fixture
|
||||
def mock_settings_store():
|
||||
with patch('openhands.server.routes.settings.SettingsStoreImpl') as mock:
|
||||
store_instance = MagicMock()
|
||||
mock.get_instance = AsyncMock(return_value=store_instance)
|
||||
store_instance.load = AsyncMock()
|
||||
store_instance.store = AsyncMock()
|
||||
yield store_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_convert_to_settings():
|
||||
with patch('openhands.server.routes.settings.convert_to_settings') as mock:
|
||||
# Make the mock function pass through the input settings
|
||||
mock.side_effect = lambda settings: settings
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_user_id():
|
||||
with patch('openhands.server.routes.settings.get_user_id') as mock:
|
||||
mock.return_value = 'test-user'
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_custom_secrets_names(test_client):
|
||||
async def test_load_custom_secrets_names(test_client, mock_settings_store):
|
||||
"""Test loading custom secrets names."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Create initial settings with custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert sorted(data['custom_secrets']) == ['API_KEY', 'DB_PASSWORD']
|
||||
|
||||
# Verify that the original settings were not modified
|
||||
stored_settings = await file_settings_store.load()
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'DB_PASSWORD'
|
||||
].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert sorted(data['custom_secrets']) == ['API_KEY', 'DB_PASSWORD']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_custom_secrets_names_empty(test_client):
|
||||
async def test_load_custom_secrets_names_empty(test_client, mock_settings_store):
|
||||
"""Test loading custom secrets names when there are no custom secrets."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Create initial settings with no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert data['custom_secrets'] == []
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert data['custom_secrets'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_custom_secret(test_client):
|
||||
async def test_add_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test adding a new custom secret."""
|
||||
# Create initial settings with provider tokens but no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with provider tokens but no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Make the POST request to add a custom secret
|
||||
add_secret_data = {'custom_secrets': {'API_KEY': 'api-key-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Make the POST request to add a custom secret
|
||||
add_secret_data = {'custom_secrets': {'API_KEY': 'api-key-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
# Verify that the settings were stored with the new secret
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Verify that the settings were stored with the new secret
|
||||
stored_settings = await file_settings_store.load()
|
||||
|
||||
# Check that the secret was added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
# Check that the secret was added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
@@ -154,274 +149,258 @@ async def test_add_custom_secret(test_client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_custom_secret(test_client):
|
||||
async def test_update_existing_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test updating an existing custom secret."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('old-api-key')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('old-api-key')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the POST request to update the custom secret
|
||||
update_secret_data = {'custom_secrets': {'API_KEY': 'new-api-key'}}
|
||||
response = test_client.post('/api/secrets', json=update_secret_data)
|
||||
assert response.status_code == 200
|
||||
# Make the POST request to update the custom secret
|
||||
update_secret_data = {'custom_secrets': {'API_KEY': 'new-api-key'}}
|
||||
response = test_client.post('/api/secrets', json=update_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the updated secret
|
||||
stored_settings = await file_settings_store.load()
|
||||
# Verify that the settings were stored with the updated secret
|
||||
stored_settings: Settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that the secret was updated
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'new-api-key'
|
||||
)
|
||||
# Check that the secret was updated
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'new-api-key'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_multiple_custom_secrets(test_client):
|
||||
async def test_add_multiple_custom_secrets(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test adding multiple custom secrets at once."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with one custom secret
|
||||
custom_secrets = {'EXISTING_SECRET': SecretStr('existing-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
# Create initial settings with one custom secret
|
||||
custom_secrets = {'EXISTING_SECRET': SecretStr('existing-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the POST request to add multiple custom secrets
|
||||
add_secrets_data = {
|
||||
'custom_secrets': {
|
||||
'API_KEY': 'api-key-value',
|
||||
'DB_PASSWORD': 'db-password-value',
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secrets_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Verify that the settings were stored with the new secrets
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Make the POST request to add multiple custom secrets
|
||||
add_secrets_data = {
|
||||
'custom_secrets': {
|
||||
'API_KEY': 'api-key-value',
|
||||
'DB_PASSWORD': 'db-password-value',
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secrets_data)
|
||||
assert response.status_code == 200
|
||||
# Check that the new secrets were added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
# Verify that the settings were stored with the new secrets
|
||||
stored_settings = await file_settings_store.load()
|
||||
# Check that existing secrets were preserved
|
||||
assert 'EXISTING_SECRET' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'EXISTING_SECRET'
|
||||
].get_secret_value()
|
||||
== 'existing-value'
|
||||
)
|
||||
|
||||
# Check that the new secrets were added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'DB_PASSWORD'
|
||||
].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
# Check that existing secrets were preserved
|
||||
assert 'EXISTING_SECRET' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'EXISTING_SECRET'
|
||||
].get_secret_value()
|
||||
== 'existing-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_custom_secret(test_client):
|
||||
async def test_delete_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test deleting a custom secret."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with multiple custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Create initial settings with multiple custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the DELETE request to delete a custom secret
|
||||
response = test_client.delete('/api/secrets/API_KEY')
|
||||
assert response.status_code == 200
|
||||
# Make the DELETE request to delete a custom secret
|
||||
response = test_client.delete('/api/secrets/API_KEY')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored without the deleted secret
|
||||
stored_settings = await file_settings_store.load()
|
||||
# Verify that the settings were stored without the deleted secret
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that the specified secret was deleted
|
||||
assert 'API_KEY' not in stored_settings.secrets_store.custom_secrets
|
||||
# Check that the specified secret was deleted
|
||||
assert 'API_KEY' not in stored_settings.secrets_store.custom_secrets
|
||||
|
||||
# Check that other secrets were preserved
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'DB_PASSWORD'
|
||||
].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
# Check that other secrets were preserved
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_custom_secret(test_client):
|
||||
async def test_delete_nonexistent_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test deleting a custom secret that doesn't exist."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('api-key-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('api-key-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the DELETE request to delete a nonexistent custom secret
|
||||
response = test_client.delete('/api/secrets/NONEXISTENT_KEY')
|
||||
assert response.status_code == 200
|
||||
# Make the DELETE request to delete a nonexistent custom secret
|
||||
response = test_client.delete('/api/secrets/NONEXISTENT_KEY')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored without changes to existing secrets
|
||||
stored_settings = await file_settings_store.load()
|
||||
# Verify that the settings were stored without changes to existing secrets
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that the existing secret was preserved
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
# Check that the existing secret was preserved
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_secrets_operations_preserve_settings(test_client):
|
||||
async def test_custom_secrets_operations_preserve_settings(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test that operations on custom secrets preserve all other settings."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with comprehensive data
|
||||
custom_secrets = {'INITIAL_SECRET': SecretStr('initial-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token')),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab-token')),
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
max_iterations=100,
|
||||
security_analyzer='default',
|
||||
confirmation_mode=True,
|
||||
llm_model='test-model',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
llm_base_url='https://test.com',
|
||||
remote_runtime_resource_factor=2,
|
||||
enable_default_condenser=True,
|
||||
enable_sound_notifications=False,
|
||||
user_consents_to_analytics=True,
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
# Create initial settings with comprehensive data
|
||||
custom_secrets = {'INITIAL_SECRET': SecretStr('initial-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token')),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab-token')),
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
max_iterations=100,
|
||||
security_analyzer='default',
|
||||
confirmation_mode=True,
|
||||
llm_model='test-model',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
llm_base_url='https://test.com',
|
||||
remote_runtime_resource_factor=2,
|
||||
enable_default_condenser=True,
|
||||
enable_sound_notifications=False,
|
||||
user_consents_to_analytics=True,
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# 1. Test adding a new custom secret
|
||||
add_secret_data = {'custom_secrets': {'NEW_SECRET': 'new-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
# 1. Test adding a new custom secret
|
||||
add_secret_data = {'custom_secrets': {'NEW_SECRET': 'new-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all settings are preserved
|
||||
stored_settings = await file_settings_store.load()
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
assert stored_settings.security_analyzer == 'default'
|
||||
assert stored_settings.confirmation_mode is True
|
||||
assert stored_settings.llm_model == 'test-model'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert stored_settings.llm_base_url == 'https://test.com'
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
assert stored_settings.enable_default_condenser is True
|
||||
assert stored_settings.enable_sound_notifications is False
|
||||
assert stored_settings.user_consents_to_analytics is True
|
||||
assert len(stored_settings.secrets_store.provider_tokens) == 2
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
assert ProviderType.GITLAB in stored_settings.secrets_store.provider_tokens
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'INITIAL_SECRET'
|
||||
].get_secret_value()
|
||||
== 'initial-value'
|
||||
)
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'NEW_SECRET'
|
||||
].get_secret_value()
|
||||
== 'new-value'
|
||||
)
|
||||
# Verify all settings are preserved
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
assert stored_settings.security_analyzer == 'default'
|
||||
assert stored_settings.confirmation_mode is True
|
||||
assert stored_settings.llm_model == 'test-model'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert stored_settings.llm_base_url == 'https://test.com'
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
assert stored_settings.enable_default_condenser is True
|
||||
assert stored_settings.enable_sound_notifications is False
|
||||
assert stored_settings.user_consents_to_analytics is True
|
||||
assert len(stored_settings.secrets_store.provider_tokens) == 2
|
||||
|
||||
# 2. Test updating an existing custom secret
|
||||
update_secret_data = {'custom_secrets': {'INITIAL_SECRET': 'updated-value'}}
|
||||
@@ -429,7 +408,7 @@ async def test_custom_secrets_operations_preserve_settings(test_client):
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all settings are still preserved
|
||||
stored_settings = await file_settings_store.load()
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
@@ -449,7 +428,7 @@ async def test_custom_secrets_operations_preserve_settings(test_client):
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all settings are still preserved
|
||||
stored_settings = await file_settings_store.load()
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
|
||||
@@ -1,60 +1,82 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.integrations.provider import ProviderType, SecretStore
|
||||
from openhands.server.app import app
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
class MockUserAuth(UserAuth):
|
||||
"""Mock implementation of UserAuth for testing"""
|
||||
|
||||
def __init__(self):
|
||||
self._settings = None
|
||||
self._settings_store = MagicMock()
|
||||
self._settings_store.load = AsyncMock(return_value=None)
|
||||
self._settings_store.store = AsyncMock()
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return 'test-user'
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
return SecretStr('test-token')
|
||||
|
||||
async def get_provider_tokens(self) -> dict[ProviderType, ProviderToken] | None: # noqa: E501
|
||||
return None
|
||||
|
||||
async def get_user_settings_store(self) -> SettingsStore | None:
|
||||
return self._settings_store
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
return MockUserAuth()
|
||||
from openhands.server.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
# Create a test client
|
||||
with patch(
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
):
|
||||
with patch(
|
||||
'openhands.server.routes.settings.validate_provider_token',
|
||||
return_value=ProviderType.GITHUB,
|
||||
):
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
def mock_settings_store():
|
||||
with patch('openhands.server.routes.settings.SettingsStoreImpl') as mock:
|
||||
store_instance = MagicMock()
|
||||
mock.get_instance = AsyncMock(return_value=store_instance)
|
||||
store_instance.load = AsyncMock()
|
||||
store_instance.store = AsyncMock()
|
||||
yield store_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_user_id():
|
||||
with patch('openhands.server.routes.settings.get_user_id') as mock:
|
||||
mock.return_value = 'test-user'
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_validate_provider_token():
|
||||
with patch('openhands.server.routes.settings.validate_provider_token') as mock:
|
||||
|
||||
async def mock_determine(*args, **kwargs):
|
||||
return ProviderType.GITHUB
|
||||
|
||||
mock.side_effect = mock_determine
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(mock_settings_store):
|
||||
# Mock the middleware that adds github_token
|
||||
class MockMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
settings = mock_settings_store.load.return_value
|
||||
token = None
|
||||
if settings and settings.secrets_store.provider_tokens.get(
|
||||
ProviderType.GITHUB
|
||||
):
|
||||
token = settings.secrets_store.provider_tokens[
|
||||
ProviderType.GITHUB
|
||||
].token
|
||||
if scope['type'] == 'http':
|
||||
scope['state'] = {'token': token}
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
# Replace the middleware
|
||||
app.middleware_stack = None # Clear existing middleware
|
||||
app.add_middleware(MockMiddleware)
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github_service():
|
||||
with patch('openhands.server.routes.settings.GitHubService') as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_endpoints(test_client):
|
||||
"""Test that the settings API endpoints work with the new auth system"""
|
||||
async def test_settings_api_runtime_factor(
|
||||
test_client, mock_settings_store, mock_get_user_id, mock_validate_provider_token
|
||||
):
|
||||
# Mock the settings store to return None initially (no existing settings)
|
||||
mock_settings_store.load.return_value = None
|
||||
|
||||
# Test data with remote_runtime_resource_factor
|
||||
settings_data = {
|
||||
@@ -70,29 +92,176 @@ async def test_settings_api_endpoints(test_client):
|
||||
'provider_tokens': {'github': 'test-token'},
|
||||
}
|
||||
|
||||
# The test_client fixture already handles authentication
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
|
||||
# We're not checking the exact response, just that it doesn't error
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test the GET settings endpoint
|
||||
# Verify the settings were stored with the correct runtime factor
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['remote_runtime_resource_factor'] == 2
|
||||
|
||||
# Verify that the sandbox config gets updated when settings are loaded
|
||||
with patch('openhands.server.shared.config') as mock_config:
|
||||
mock_config.sandbox = SandboxConfig()
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the sandbox config was updated with the new value
|
||||
mock_settings_store.store.assert_called()
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
|
||||
assert isinstance(stored_settings.llm_api_key, SecretStr)
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_llm_api_key(
|
||||
test_client, mock_settings_store, mock_get_user_id, mock_validate_provider_token
|
||||
):
|
||||
# Mock the settings store to return None initially (no existing settings)
|
||||
mock_settings_store.load.return_value = None
|
||||
|
||||
# Test data with remote_runtime_resource_factor
|
||||
settings_data = {
|
||||
'llm_api_key': 'test-key',
|
||||
'provider_tokens': {'github': 'test-token'},
|
||||
}
|
||||
|
||||
# The test_client fixture already handles authentication
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the settings were stored with the correct secret API key
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert isinstance(stored_settings.llm_api_key, SecretStr)
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-key'
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test updating with partial settings
|
||||
partial_settings = {
|
||||
'language': 'fr',
|
||||
'llm_model': None, # Should preserve existing value
|
||||
'llm_api_key': None, # Should preserve existing value
|
||||
# We should never expose the API key in the response
|
||||
assert 'test-key' not in response.json()
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason='Mock middleware does not seem to properly set the github_token'
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_set_github_token(
|
||||
mock_github_service,
|
||||
test_client,
|
||||
mock_settings_store,
|
||||
mock_get_user_id,
|
||||
mock_validate_provider_token,
|
||||
):
|
||||
# Test data with provider token set
|
||||
settings_data = {
|
||||
'language': 'en',
|
||||
'agent': 'test-agent',
|
||||
'max_iterations': 100,
|
||||
'security_analyzer': 'default',
|
||||
'confirmation_mode': True,
|
||||
'llm_model': 'test-model',
|
||||
'llm_api_key': 'test-key',
|
||||
'llm_base_url': 'https://test.com',
|
||||
'provider_tokens': {'github': 'test-token'},
|
||||
}
|
||||
|
||||
response = test_client.post('/api/settings', json=partial_settings)
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test the unset-settings-tokens endpoint
|
||||
response = test_client.post('/api/unset-settings-tokens')
|
||||
# Verify the settings were stored with the provider token
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert (
|
||||
stored_settings.secrets_store.provider_tokens[
|
||||
ProviderType.GITHUB
|
||||
].token.get_secret_value()
|
||||
== 'test-token'
|
||||
)
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
response = test_client.get('/api/settings')
|
||||
data = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data.get('token') is None
|
||||
assert data['token_is_set'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_preserve_llm_fields_when_none(test_client, mock_settings_store):
|
||||
# Setup initial settings with LLM fields populated
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
max_iterations=100,
|
||||
security_analyzer='default',
|
||||
confirmation_mode=True,
|
||||
llm_model='existing-model',
|
||||
llm_api_key=SecretStr('existing-key'),
|
||||
llm_base_url='https://existing.com',
|
||||
secrets_store=SecretStore(),
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Test data with None values for LLM fields
|
||||
settings_update = {
|
||||
'language': 'fr', # Change something else to verify the update happens
|
||||
'llm_model': None,
|
||||
'llm_api_key': None,
|
||||
'llm_base_url': None,
|
||||
}
|
||||
|
||||
# Make the POST request to update settings
|
||||
response = test_client.post('/api/settings', json=settings_update)
|
||||
assert response.status_code == 200
|
||||
|
||||
# We'll skip the secrets endpoints for now as they require more complex mocking # noqa: E501
|
||||
# and they're not directly related to the authentication refactoring
|
||||
# Verify that the settings were stored with preserved LLM values
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that language was updated
|
||||
assert stored_settings.language == 'fr'
|
||||
|
||||
# Check that LLM fields were preserved and not cleared
|
||||
assert stored_settings.llm_model == 'existing-model'
|
||||
assert isinstance(stored_settings.llm_api_key, SecretStr)
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'existing-key'
|
||||
assert stored_settings.llm_base_url == 'https://existing.com'
|
||||
|
||||
# Update the mock to return our new settings for the GET request
|
||||
mock_settings_store.load.return_value = stored_settings
|
||||
|
||||
# Make a GET request to verify the updated settings
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify fields in the response
|
||||
assert data['language'] == 'fr'
|
||||
assert data['llm_model'] == 'existing-model'
|
||||
assert data['llm_base_url'] == 'https://existing.com'
|
||||
# We expect the API key not to be included in the response
|
||||
assert 'test-key' not in str(response.content)
|
||||
|
||||
@@ -23,6 +23,7 @@ async def get_settings_store(request):
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_valid():
|
||||
"""Test check_provider_tokens with valid tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'valid-token'})
|
||||
|
||||
# Mock the validate_provider_token function to return GITHUB for valid tokens
|
||||
@@ -31,7 +32,7 @@ async def test_check_provider_tokens_valid():
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = ProviderType.GITHUB
|
||||
|
||||
result = await check_provider_tokens(settings)
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return empty string for valid token
|
||||
assert result == ''
|
||||
@@ -41,6 +42,7 @@ async def test_check_provider_tokens_valid():
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_invalid():
|
||||
"""Test check_provider_tokens with invalid tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'invalid-token'})
|
||||
|
||||
# Mock the validate_provider_token function to return None for invalid tokens
|
||||
@@ -49,7 +51,7 @@ async def test_check_provider_tokens_invalid():
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = None
|
||||
|
||||
result = await check_provider_tokens(settings)
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return error message for invalid token
|
||||
assert 'Invalid token' in result
|
||||
@@ -59,9 +61,10 @@ async def test_check_provider_tokens_invalid():
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_wrong_type():
|
||||
"""Test check_provider_tokens with unsupported provider type."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'unsupported': 'some-token'})
|
||||
|
||||
result = await check_provider_tokens(settings)
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return empty string for unsupported provider
|
||||
assert result == ''
|
||||
@@ -70,9 +73,10 @@ async def test_check_provider_tokens_wrong_type():
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_no_tokens():
|
||||
"""Test check_provider_tokens with no tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={})
|
||||
|
||||
result = await check_provider_tokens(settings)
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return empty string when no tokens provided
|
||||
assert result == ''
|
||||
@@ -82,6 +86,7 @@ async def test_check_provider_tokens_no_tokens():
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_new_settings():
|
||||
"""Test store_llm_settings with new settings."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
llm_model='gpt-4',
|
||||
llm_api_key='test-api-key',
|
||||
@@ -89,20 +94,25 @@ async def test_store_llm_settings_new_settings():
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
result = await store_llm_settings(mock_request, settings)
|
||||
|
||||
# Should return settings with the provided values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'test-api-key'
|
||||
assert result.llm_base_url == 'https://api.example.com'
|
||||
# Should return settings with the provided values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'test-api-key'
|
||||
assert result.llm_base_url == 'https://api.example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_update_existing():
|
||||
"""Test store_llm_settings updates existing settings."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
llm_model='gpt-4',
|
||||
llm_api_key='new-api-key',
|
||||
@@ -110,118 +120,142 @@ async def test_store_llm_settings_update_existing():
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('old-api-key'),
|
||||
llm_base_url='https://old.example.com',
|
||||
)
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('old-api-key'),
|
||||
llm_base_url='https://old.example.com',
|
||||
)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
result = await store_llm_settings(mock_request, settings)
|
||||
|
||||
# Should return settings with the updated values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert result.llm_base_url == 'https://new.example.com'
|
||||
# Should return settings with the updated values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert result.llm_base_url == 'https://new.example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_partial_update():
|
||||
"""Test store_llm_settings with partial update."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
llm_model='gpt-4' # Only updating model
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('existing-api-key'),
|
||||
llm_base_url='https://existing.example.com',
|
||||
)
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('existing-api-key'),
|
||||
llm_base_url='https://existing.example.com',
|
||||
)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
result = await store_llm_settings(mock_request, settings)
|
||||
|
||||
# Should return settings with updated model but keep other values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
# For SecretStr objects, we need to compare the secret value
|
||||
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
|
||||
assert result.llm_base_url == 'https://existing.example.com'
|
||||
# Should return settings with updated model but keep other values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
# For SecretStr objects, we need to compare the secret value
|
||||
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
|
||||
assert result.llm_base_url == 'https://existing.example.com'
|
||||
|
||||
|
||||
# Tests for store_provider_tokens
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_provider_tokens_new_tokens():
|
||||
"""Test store_provider_tokens with new tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'new-token'})
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
result = await store_provider_tokens(settings, mock_store)
|
||||
result = await store_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return settings with the provided tokens
|
||||
assert result.provider_tokens == {'github': 'new-token'}
|
||||
# Should return settings with the provided tokens
|
||||
assert result.provider_tokens == {'github': 'new-token'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_provider_tokens_update_existing():
|
||||
"""Test store_provider_tokens updates existing tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'updated-token'})
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('old-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('old-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
result = await store_provider_tokens(settings, mock_store)
|
||||
result = await store_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return settings with the updated tokens
|
||||
assert result.provider_tokens == {'github': 'updated-token'}
|
||||
# Should return settings with the updated tokens
|
||||
assert result.provider_tokens == {'github': 'updated-token'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_provider_tokens_keep_existing():
|
||||
"""Test store_provider_tokens keeps existing tokens when empty string provided."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
provider_tokens={'github': ''} # Empty string should keep existing token
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
mock_store = MagicMock()
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('existing-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('existing-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
result = await store_provider_tokens(settings, mock_store)
|
||||
result = await store_provider_tokens(mock_request, settings)
|
||||
|
||||
# Should return settings with the existing token preserved
|
||||
assert result.provider_tokens == {'github': 'existing-token'}
|
||||
# Should return settings with the existing token preserved
|
||||
assert result.provider_tokens == {'github': 'existing-token'}
|
||||
|
||||
Reference in New Issue
Block a user