mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dad3c0c0be | |||
| 141cced78f | |||
| bfaef08d1f |
@@ -11,5 +11,5 @@ jobs:
|
||||
uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main
|
||||
if: github.event.label.name == 'fix-me'
|
||||
with:
|
||||
max_iterations: 50
|
||||
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
secrets: inherit
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
### 9. Use existing Docker image
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
|
||||
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.12-nikolaik
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -38,15 +38,15 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -32,8 +32,7 @@ workspace_base = "./workspace"
|
||||
# Enable saving and restoring the session when run from CLI
|
||||
#enable_cli_session = false
|
||||
|
||||
# Path to store trajectories, can be a folder or a file
|
||||
# If it's a folder, the session id will be used as the file name
|
||||
# Path to store trajectories
|
||||
#trajectories_path="./trajectories"
|
||||
|
||||
# File store path
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -53,6 +53,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.13
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.12
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -4,11 +4,11 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
|
||||
|
||||
## Model Recommendations
|
||||
|
||||
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and [this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
|
||||
Based on a recent evaluation of language models for coding tasks (using the SWE-bench dataset), we can provide some recommendations for model selection. The full analysis can be found in [this blog article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed).
|
||||
|
||||
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
|
||||
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
|
||||
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 27% resolve rate with the default agent in OpenHands.
|
||||
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
|
||||
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
|
||||
|
||||
|
||||
@@ -35,8 +35,7 @@ def codeact_user_response_eda(state: State) -> str:
|
||||
|
||||
# retrieve the latest model message from history
|
||||
if state.history:
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_guess = last_agent_message.content if last_agent_message else ''
|
||||
model_guess = state.get_last_agent_message()
|
||||
|
||||
assert game is not None, 'Game is not initialized.'
|
||||
msg = game.generate_user_response(model_guess)
|
||||
@@ -141,8 +140,7 @@ def process_instance(
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
final_message = last_agent_message.content if last_agent_message else ''
|
||||
final_message = state.get_last_agent_message()
|
||||
|
||||
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
|
||||
test_result = game.reward()
|
||||
|
||||
@@ -102,8 +102,7 @@ def process_instance(
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
model_answer_raw = state.get_last_agent_message()
|
||||
|
||||
# attempt to parse model_answer
|
||||
ast_eval_fn = instance['ast_eval']
|
||||
|
||||
@@ -83,7 +83,6 @@ def get_config(instance: pd.Series) -> AppConfig:
|
||||
timeout=1800,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -146,7 +146,6 @@ def get_config(
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_remote_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -127,8 +127,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# retrieve the last message from the agent
|
||||
last_agent_message = state.get_last_agent_message()
|
||||
model_answer_raw = last_agent_message.content if last_agent_message else ''
|
||||
model_answer_raw = state.get_last_agent_message()
|
||||
|
||||
# attempt to parse model_answer
|
||||
correct = eval_answer(str(model_answer_raw), str(answer))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
||||
import { ChatInput } from "#/components/chat-input";
|
||||
|
||||
@@ -158,46 +158,4 @@ describe("ChatInput", () => {
|
||||
await user.tab();
|
||||
expect(onBlurMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle text paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Fire paste event with text data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => type === 'text/plain' ? 'test paste' : '',
|
||||
files: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle image paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onImagePaste = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Create a paste event with an image file
|
||||
const file = new File(["dummy content"], "image.png", { type: "image/png" });
|
||||
|
||||
// Fire paste event with image data
|
||||
fireEvent.paste(input!, {
|
||||
clipboardData: {
|
||||
getData: () => '',
|
||||
files: [file]
|
||||
}
|
||||
});
|
||||
|
||||
// Verify image paste was handled
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ describe("Cache", () => {
|
||||
const testTTL = 1000; // 1 second
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -15,7 +16,17 @@ describe("Cache", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("gets data from memory if not expired", () => {
|
||||
it("sets data in localStorage with expiration", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
const cachedEntry = JSON.parse(
|
||||
localStorage.getItem(`app_cache_${testKey}`) || "",
|
||||
);
|
||||
|
||||
expect(cachedEntry.data).toEqual(testData);
|
||||
expect(cachedEntry.expiration).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("gets data from localStorage if not expired", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
|
||||
expect(cache.get(testKey)).toEqual(testData);
|
||||
@@ -28,6 +39,7 @@ describe("Cache", () => {
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if cached data is expired", () => {
|
||||
@@ -35,19 +47,28 @@ describe("Cache", () => {
|
||||
|
||||
vi.advanceTimersByTime(testTTL + 1);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes data from memory", () => {
|
||||
it("deletes data from localStorage", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.delete(testKey);
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
|
||||
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all data with the app prefix from memory", () => {
|
||||
it("clears all data with the app prefix from localStorage", () => {
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.set("anotherKey", { data: "More data" }, testTTL);
|
||||
cache.clearAll();
|
||||
expect(cache.get(testKey)).toBeNull();
|
||||
expect(cache.get("anotherKey")).toBeNull();
|
||||
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not retrieve non-prefixed data from localStorage when clearing", () => {
|
||||
localStorage.setItem("nonPrefixedKey", "should remain");
|
||||
cache.set(testKey, testData, testTTL);
|
||||
cache.clearAll();
|
||||
expect(localStorage.getItem("nonPrefixedKey")).toBe("should remain");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,9 +59,9 @@ describe("extractModelAndProvider", () => {
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20240620")).toEqual({
|
||||
expect(extractModelAndProvider("claude-3-5-sonnet-20241022")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ test("organizeModelsAndProviders", () => {
|
||||
"gpt-4o",
|
||||
"together-ai-21.1b-41b",
|
||||
"gpt-4o-mini",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.3",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -120,4 +120,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,18 +40,13 @@ export function ChatInput({
|
||||
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Only handle paste if we have an image paste handler and there are files
|
||||
event.preventDefault();
|
||||
if (onImagePaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
// Only prevent default if we found image files to handle
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePaste(files);
|
||||
}
|
||||
if (files.length > 0) onImagePaste(files);
|
||||
}
|
||||
// For text paste, let the default behavior handle it
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
|
||||
@@ -6,7 +6,6 @@ import ModalBody from "./ModalBody";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
import FormFieldset from "../form/FormFieldset";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction as settingsClientAction } from "#/routes/settings";
|
||||
import { clientAction as loginClientAction } from "#/routes/login";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
@@ -25,8 +24,8 @@ function AccountSettingsModal({
|
||||
gitHubError,
|
||||
analyticsConsent,
|
||||
}: AccountSettingsModalProps) {
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
const { t } = useTranslation();
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const settingsFetcher = useFetcher<typeof settingsClientAction>({
|
||||
key: "settings",
|
||||
});
|
||||
@@ -36,7 +35,7 @@ function AccountSettingsModal({
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const language = formData.get("language")?.toString();
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
const newGHToken = formData.get("ghToken")?.toString();
|
||||
const analytics = formData.get("analytics")?.toString() === "on";
|
||||
|
||||
const accountForm = new FormData();
|
||||
@@ -49,7 +48,7 @@ function AccountSettingsModal({
|
||||
)?.value;
|
||||
accountForm.append("language", languageKey ?? "en");
|
||||
}
|
||||
if (ghToken) loginForm.append("ghToken", ghToken);
|
||||
if (newGHToken) loginForm.append("ghToken", newGHToken);
|
||||
accountForm.append("analytics", analytics.toString());
|
||||
|
||||
settingsFetcher.submit(accountForm, {
|
||||
@@ -85,14 +84,14 @@ function AccountSettingsModal({
|
||||
name="ghToken"
|
||||
label="GitHub Token"
|
||||
type="password"
|
||||
defaultValue={data?.ghToken ?? ""}
|
||||
defaultValue={ghToken ?? ""}
|
||||
/>
|
||||
{gitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{data?.ghToken && !gitHubError && (
|
||||
{ghToken && !gitHubError && (
|
||||
<ModalButton
|
||||
variant="text-like"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModalBody from "./ModalBody";
|
||||
import { CustomInput } from "../form/custom-input";
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "./confirmation-modals/BaseModal";
|
||||
import { clientLoader } from "#/routes/_oh";
|
||||
import { clientAction } from "#/routes/login";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -16,7 +15,7 @@ interface ConnectToGitHubModalProps {
|
||||
}
|
||||
|
||||
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
|
||||
const ghToken = localStorage.getItem("ghToken");
|
||||
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -51,7 +50,7 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
|
||||
name="ghToken"
|
||||
required
|
||||
type="password"
|
||||
defaultValue={data?.ghToken ?? ""}
|
||||
defaultValue={ghToken ?? ""}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
|
||||
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
|
||||
import { ProjectMenuDetails } from "./project-menu-details";
|
||||
import { downloadWorkspace } from "#/utils/download-workspace";
|
||||
import { LoadingSpinner } from "../modals/LoadingProject";
|
||||
|
||||
interface ProjectMenuCardProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -33,7 +32,6 @@ export function ProjectMenuCard({
|
||||
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [working, setWorking] = React.useState(false);
|
||||
|
||||
const toggleMenuVisibility = () => {
|
||||
setContextMenuIsOpen((prev) => !prev);
|
||||
@@ -43,7 +41,10 @@ export function ProjectMenuCard({
|
||||
posthog.capture("push_to_github_button_clicked");
|
||||
const rawEvent = {
|
||||
content: `
|
||||
Please push the changes to GitHub and open a pull request.
|
||||
Let's push the code to GitHub.
|
||||
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
|
||||
Commit any changes and push them to the remote repository.
|
||||
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
|
||||
`,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -62,11 +63,7 @@ Please push the changes to GitHub and open a pull request.
|
||||
const handleDownloadWorkspace = () => {
|
||||
posthog.capture("download_workspace_button_clicked");
|
||||
try {
|
||||
setWorking(true);
|
||||
downloadWorkspace().then(
|
||||
() => setWorking(false),
|
||||
() => setWorking(false),
|
||||
);
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
@@ -74,7 +71,7 @@ Please push the changes to GitHub and open a pull request.
|
||||
|
||||
return (
|
||||
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
|
||||
{!working && contextMenuIsOpen && (
|
||||
{contextMenuIsOpen && (
|
||||
<ProjectMenuCardContextMenu
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
@@ -101,11 +98,7 @@ Please push the changes to GitHub and open a pull request.
|
||||
onClick={toggleMenuVisibility}
|
||||
aria-label="Open project menu"
|
||||
>
|
||||
{working ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<EllipsisH width={36} height={36} />
|
||||
)}
|
||||
<EllipsisH width={36} height={36} />
|
||||
</button>
|
||||
{connectToGitHubModalOpen && (
|
||||
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ProjectMenuCardContextMenuProps {
|
||||
isConnectedToGitHub: boolean;
|
||||
@@ -20,7 +18,7 @@ export function ProjectMenuCardContextMenu({
|
||||
onClose,
|
||||
}: ProjectMenuCardContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
@@ -28,16 +26,16 @@ export function ProjectMenuCardContextMenu({
|
||||
>
|
||||
{!isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onConnectToGitHub}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
|
||||
Connect to GitHub
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{isConnectedToGitHub && (
|
||||
<ContextMenuListItem onClick={onPushToGitHub}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
|
||||
Push to GitHub
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem onClick={onDownloadWorkspace}>
|
||||
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
|
||||
Download as .zip
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -535,8 +535,7 @@
|
||||
"pt": "Socket não inicializado",
|
||||
"ko-KR": "소켓이 초기화되지 않았습니다",
|
||||
"ar": "لم يتم تهيئة Socket",
|
||||
"tr": "Soket başlatılmadı",
|
||||
"no": "Socket ikke initialisert"
|
||||
"tr": "Soket başlatılmadı"
|
||||
},
|
||||
"EXPLORER$UPLOAD_ERROR_MESSAGE": {
|
||||
"en": "Error uploading file",
|
||||
@@ -549,8 +548,7 @@
|
||||
"pt": "Erro ao fazer upload do arquivo",
|
||||
"ko-KR": "파일 업로드 중 오류 발생",
|
||||
"ar": "خطأ في تحميل الملف",
|
||||
"tr": "Dosya yüklenirken hata oluştu",
|
||||
"no": "Feil ved opplasting av fil"
|
||||
"tr": "Dosya yüklenirken hata oluştu"
|
||||
},
|
||||
"EXPLORER$LABEL_DROP_FILES": {
|
||||
"en": "Drop files here",
|
||||
@@ -559,7 +557,6 @@
|
||||
"zh-TW": "將檔案拖曳至此",
|
||||
"es": "Suelta los archivos aquí",
|
||||
"fr": "Déposez les fichiers ici",
|
||||
"no": "Slipp filer her",
|
||||
"it": "Trascina i file qui",
|
||||
"pt": "Solte os arquivos aqui",
|
||||
"ko-KR": "파일을 여기에 놓으세요",
|
||||
@@ -577,8 +574,7 @@
|
||||
"pt": "Espaço de trabalho",
|
||||
"ko-KR": "작업 공간",
|
||||
"ar": "مساحة العمل",
|
||||
"tr": "Çalışma alanı",
|
||||
"no": "Arbeidsområde"
|
||||
"tr": "Çalışma alanı"
|
||||
},
|
||||
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
|
||||
"en": "No files in workspace",
|
||||
@@ -591,8 +587,7 @@
|
||||
"pt": "Nenhum arquivo no espaço de trabalho",
|
||||
"ko-KR": "작업 공간에 파일이 없습니다",
|
||||
"ar": "لا توجد ملفات في مساحة العمل",
|
||||
"tr": "Çalışma alanında dosya yok",
|
||||
"no": "Ingen filer i arbeidsområdet"
|
||||
"tr": "Çalışma alanında dosya yok"
|
||||
},
|
||||
"EXPLORER$LOADING_WORKSPACE_MESSAGE": {
|
||||
"en": "Loading workspace...",
|
||||
@@ -605,8 +600,7 @@
|
||||
"pt": "Carregando espaço de trabalho...",
|
||||
"ko-KR": "작업 공간 로딩 중...",
|
||||
"ar": "جارٍ تحميل مساحة العمل...",
|
||||
"tr": "Çalışma alanı yükleniyor...",
|
||||
"no": "Laster arbeidsområde..."
|
||||
"tr": "Çalışma alanı yükleniyor..."
|
||||
},
|
||||
"EXPLORER$REFRESH_ERROR_MESSAGE": {
|
||||
"en": "Error refreshing workspace",
|
||||
@@ -619,8 +613,7 @@
|
||||
"pt": "Erro ao atualizar o espaço de trabalho",
|
||||
"ko-KR": "작업 공간 새로 고침 오류",
|
||||
"ar": "خطأ في تحديث مساحة العمل",
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu",
|
||||
"no": "Feil ved oppdatering av arbeidsområde"
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu"
|
||||
},
|
||||
"EXPLORER$UPLOAD_SUCCESS_MESSAGE": {
|
||||
"en": "Successfully uploaded {{count}} file(s)",
|
||||
@@ -633,8 +626,7 @@
|
||||
"pt": "{{count}} arquivo(s) carregado(s) com sucesso",
|
||||
"ko-KR": "{{count}}개의 파일을 성공적으로 업로드했습니다",
|
||||
"ar": "تم تحميل {{count}} ملف (ملفات) بنجاح",
|
||||
"tr": "{{count}} dosya başarıyla yüklendi",
|
||||
"no": "Lastet opp {{count}} fil(er) vellykket"
|
||||
"tr": "{{count}} dosya başarıyla yüklendi"
|
||||
},
|
||||
"EXPLORER$NO_FILES_UPLOADED_MESSAGE": {
|
||||
"en": "No files were uploaded",
|
||||
@@ -647,8 +639,7 @@
|
||||
"pt": "Nenhum arquivo foi carregado",
|
||||
"ko-KR": "업로드된 파일이 없습니다",
|
||||
"ar": "لم يتم تحميل أي ملفات",
|
||||
"tr": "Hiçbir dosya yüklenmedi",
|
||||
"no": "Ingen filer ble lastet opp"
|
||||
"tr": "Hiçbir dosya yüklenmedi"
|
||||
},
|
||||
"EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": {
|
||||
"en": "{{count}} file(s) were skipped during upload",
|
||||
@@ -661,8 +652,7 @@
|
||||
"pt": "{{count}} arquivo(s) foram ignorados durante o upload",
|
||||
"ko-KR": "업로드 중 {{count}}개의 파일이 건너뛰어졌습니다",
|
||||
"ar": "تم تخطي {{count}} ملف (ملفات) أثناء التحميل",
|
||||
"tr": "Yükleme sırasında {{count}} dosya atlandı",
|
||||
"no": "{{count}} fil(er) ble hoppet over under opplasting"
|
||||
"tr": "Yükleme sırasında {{count}} dosya atlandı"
|
||||
},
|
||||
"EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": {
|
||||
"en": "Unexpected response structure from server",
|
||||
@@ -675,8 +665,7 @@
|
||||
"pt": "Estrutura de resposta inesperada do servidor",
|
||||
"ko-KR": "서버로부터 예상치 못한 응답 구조",
|
||||
"ar": "بنية استجابة غير متوقعة من الخادم",
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı",
|
||||
"no": "Uventet responsstruktur fra serveren"
|
||||
"tr": "Sunucudan beklenmeyen yanıt yapısı"
|
||||
},
|
||||
"LOAD_SESSION$MODAL_TITLE": {
|
||||
"en": "Return to existing session?",
|
||||
@@ -810,325 +799,95 @@
|
||||
},
|
||||
"FEEDBACK$EMAIL_PLACEHOLDER": {
|
||||
"en": "Enter your email address",
|
||||
"es": "Ingresa tu correo electrónico",
|
||||
"zh-CN": "输入您的电子邮件地址",
|
||||
"zh-TW": "輸入您的電子郵件地址",
|
||||
"ko-KR": "이메일 주소를 입력하세요",
|
||||
"no": "Skriv inn din e-postadresse",
|
||||
"ar": "أدخل عنوان بريدك الإلكتروني",
|
||||
"de": "Geben Sie Ihre E-Mail-Adresse ein",
|
||||
"fr": "Entrez votre adresse e-mail",
|
||||
"it": "Inserisci il tuo indirizzo email",
|
||||
"pt": "Digite seu endereço de e-mail",
|
||||
"tr": "E-posta adresinizi girin"
|
||||
"es": "Ingresa tu correo electrónico"
|
||||
},
|
||||
"FEEDBACK$PASSWORD_COPIED_MESSAGE": {
|
||||
"en": "Password copied to clipboard.",
|
||||
"es": "Contraseña copiada al portapapeles.",
|
||||
"zh-CN": "密码已复制到剪贴板。",
|
||||
"zh-TW": "密碼已複製到剪貼板。",
|
||||
"ko-KR": "비밀번호가 클립보드에 복사되었습니다.",
|
||||
"no": "Passord kopiert til utklippstavlen.",
|
||||
"ar": "تم نسخ كلمة المرور إلى الحافظة.",
|
||||
"de": "Passwort in die Zwischenablage kopiert.",
|
||||
"fr": "Mot de passe copié dans le presse-papiers.",
|
||||
"it": "Password copiata negli appunti.",
|
||||
"pt": "Senha copiada para a área de transferência.",
|
||||
"tr": "Parola panoya kopyalandı."
|
||||
"es": "Contraseña copiada al portapapeles."
|
||||
},
|
||||
"FEEDBACK$GO_TO_FEEDBACK": {
|
||||
"en": "Go to shared feedback",
|
||||
"es": "Ir a feedback compartido",
|
||||
"zh-CN": "转到共享反馈",
|
||||
"zh-TW": "前往共享反饋",
|
||||
"ko-KR": "공유된 피드백으로 이동",
|
||||
"no": "Gå til delt tilbakemelding",
|
||||
"ar": "الذهاب إلى التعليقات المشتركة",
|
||||
"de": "Zum geteilten Feedback gehen",
|
||||
"fr": "Aller aux commentaires partagés",
|
||||
"it": "Vai al feedback condiviso",
|
||||
"pt": "Ir para feedback compartilhado",
|
||||
"tr": "Paylaşılan geri bildirimlere git"
|
||||
"es": "Ir a feedback compartido"
|
||||
},
|
||||
"FEEDBACK$PASSWORD": {
|
||||
"en": "Password:",
|
||||
"es": "Contraseña:",
|
||||
"zh-CN": "密码:",
|
||||
"zh-TW": "密碼:",
|
||||
"ko-KR": "비밀번호:",
|
||||
"no": "Passord:",
|
||||
"ar": "كلمة المرور:",
|
||||
"de": "Passwort:",
|
||||
"fr": "Mot de passe :",
|
||||
"it": "Password:",
|
||||
"pt": "Senha:",
|
||||
"tr": "Parola:"
|
||||
"es": "Contraseña:"
|
||||
},
|
||||
"FEEDBACK$INVALID_EMAIL_FORMAT": {
|
||||
"en": "Invalid email format",
|
||||
"es": "Formato de correo inválido",
|
||||
"zh-CN": "无效的电子邮件格式",
|
||||
"zh-TW": "無效的電子郵件格式",
|
||||
"ko-KR": "잘못된 이메일 형식",
|
||||
"no": "Ugyldig e-postformat",
|
||||
"ar": "تنسيق البريد الإلكتروني غير صالح",
|
||||
"de": "Ungültiges E-Mail-Format",
|
||||
"fr": "Format d'e-mail invalide",
|
||||
"it": "Formato email non valido",
|
||||
"pt": "Formato de e-mail inválido",
|
||||
"tr": "Geçersiz e-posta biçimi"
|
||||
"es": "Formato de correo inválido"
|
||||
},
|
||||
"FEEDBACK$FAILED_TO_SHARE": {
|
||||
"en": "Failed to share, please contact the developers:",
|
||||
"es": "Error al compartir, por favor contacta con los desarrolladores:",
|
||||
"zh-CN": "分享失败,请联系开发人员:",
|
||||
"zh-TW": "分享失敗,請聯繫開發人員:",
|
||||
"ko-KR": "공유 실패, 개발자에게 문의하세요:",
|
||||
"no": "Deling mislyktes, vennligst kontakt utviklerne:",
|
||||
"ar": "فشل المشاركة، يرجى الاتصال بالمطورين:",
|
||||
"de": "Teilen fehlgeschlagen, bitte kontaktieren Sie die Entwickler:",
|
||||
"fr": "Échec du partage, veuillez contacter les développeurs :",
|
||||
"it": "Condivisione fallita, contattare gli sviluppatori:",
|
||||
"pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:",
|
||||
"tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:"
|
||||
"es": "Error al compartir, por favor contacta con los desarrolladores:"
|
||||
},
|
||||
"FEEDBACK$COPY_LABEL": {
|
||||
"en": "Copy",
|
||||
"es": "Copiar",
|
||||
"zh-CN": "复制",
|
||||
"zh-TW": "複製",
|
||||
"ko-KR": "복사",
|
||||
"no": "Kopier",
|
||||
"ar": "نسخ",
|
||||
"de": "Kopieren",
|
||||
"fr": "Copier",
|
||||
"it": "Copia",
|
||||
"pt": "Copiar",
|
||||
"tr": "Kopyala"
|
||||
"es": "Copiar"
|
||||
},
|
||||
"FEEDBACK$SHARING_SETTINGS_LABEL": {
|
||||
"en": "Sharing settings",
|
||||
"es": "Configuración de compartir",
|
||||
"zh-CN": "共享设置",
|
||||
"zh-TW": "共享設定",
|
||||
"ko-KR": "공유 설정",
|
||||
"no": "Delingsinnstillinger",
|
||||
"ar": "إعدادات المشاركة",
|
||||
"de": "Freigabeeinstellungen",
|
||||
"fr": "Paramètres de partage",
|
||||
"it": "Impostazioni di condivisione",
|
||||
"pt": "Configurações de compartilhamento",
|
||||
"tr": "Paylaşım ayarları"
|
||||
"es": "Configuración de compartir"
|
||||
},
|
||||
"SECURITY$UNKNOWN_ANALYZER_LABEL":{
|
||||
"en": "Unknown security analyzer chosen",
|
||||
"es": "Analizador de seguridad desconocido",
|
||||
"zh-CN": "选择了未知的安全分析器",
|
||||
"zh-TW": "選擇了未知的安全分析器",
|
||||
"ko-KR": "알 수 없는 보안 분석기가 선택되었습니다",
|
||||
"no": "Ukjent sikkerhetsanalysator valgt",
|
||||
"ar": "تم اختيار محلل أمان غير معروف",
|
||||
"de": "Unbekannter Sicherheitsanalysator ausgewählt",
|
||||
"fr": "Analyseur de sécurité inconnu choisi",
|
||||
"it": "Analizzatore di sicurezza sconosciuto selezionato",
|
||||
"pt": "Analisador de segurança desconhecido escolhido",
|
||||
"tr": "Bilinmeyen güvenlik analizörü seçildi"
|
||||
"es": "Analizador de seguridad desconocido"
|
||||
},
|
||||
"INVARIANT$UPDATE_POLICY_LABEL": {
|
||||
"en": "Update Policy",
|
||||
"es": "Actualizar política",
|
||||
"zh-CN": "更新策略",
|
||||
"zh-TW": "更新策略",
|
||||
"ko-KR": "정책 업데이트",
|
||||
"no": "Oppdater policy",
|
||||
"ar": "تحديث السياسة",
|
||||
"de": "Richtlinie aktualisieren",
|
||||
"fr": "Mettre à jour la politique",
|
||||
"it": "Aggiorna policy",
|
||||
"pt": "Atualizar política",
|
||||
"tr": "İlkeyi güncelle"
|
||||
"es": "Actualizar política"
|
||||
},
|
||||
"INVARIANT$UPDATE_SETTINGS_LABEL": {
|
||||
"en": "Update Settings",
|
||||
"es": "Actualizar configuración",
|
||||
"zh-CN": "更新设置",
|
||||
"zh-TW": "更新設定",
|
||||
"ko-KR": "설정 업데이트",
|
||||
"no": "Oppdater innstillinger",
|
||||
"ar": "تحديث الإعدادات",
|
||||
"de": "Einstellungen aktualisieren",
|
||||
"fr": "Mettre à jour les paramètres",
|
||||
"it": "Aggiorna impostazioni",
|
||||
"pt": "Atualizar configurações",
|
||||
"tr": "Ayarları güncelle"
|
||||
"es": "Actualizar configuración"
|
||||
},
|
||||
"INVARIANT$SETTINGS_LABEL": {
|
||||
"en": "Settings",
|
||||
"es": "Configuración",
|
||||
"zh-CN": "设置",
|
||||
"zh-TW": "設定",
|
||||
"ko-KR": "설정",
|
||||
"no": "Innstillinger",
|
||||
"ar": "الإعدادات",
|
||||
"de": "Einstellungen",
|
||||
"fr": "Paramètres",
|
||||
"it": "Impostazioni",
|
||||
"pt": "Configurações",
|
||||
"tr": "Ayarlar"
|
||||
"es": "Configuración"
|
||||
},
|
||||
"INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": {
|
||||
"en": "Ask for user confirmation on risk severity:",
|
||||
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:",
|
||||
"zh-CN": "询问用户确认风险等级:",
|
||||
"zh-TW": "詢問用戶確認風險等級:",
|
||||
"ko-KR": "위험 심각도에 대한 사용자 확인 요청:",
|
||||
"no": "Be om brukerbekreftelse på risikoalvorlighet:",
|
||||
"ar": "اطلب تأكيد المستخدم على مستوى الخطورة:",
|
||||
"de": "Nach Benutzerbestätigung für Risikoschweregrad fragen:",
|
||||
"fr": "Demander la confirmation de l'utilisateur sur la gravité du risque :",
|
||||
"it": "Chiedi conferma all'utente sulla gravità del rischio:",
|
||||
"pt": "Solicitar confirmação do usuário sobre a gravidade do risco:",
|
||||
"tr": "Risk şiddeti için kullanıcı onayı iste:"
|
||||
"es": "Preguntar por confirmación del usuario sobre severidad del riesgo:"
|
||||
},
|
||||
"INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": {
|
||||
"en": "Don't ask for confirmation",
|
||||
"es": "No solicitar confirmación",
|
||||
"zh-CN": "不要请求确认",
|
||||
"zh-TW": "不要請求確認",
|
||||
"ko-KR": "확인 요청하지 않음",
|
||||
"no": "Ikke spør om bekreftelse",
|
||||
"ar": "لا تطلب التأكيد",
|
||||
"de": "Nicht nach Bestätigung fragen",
|
||||
"fr": "Ne pas demander de confirmation",
|
||||
"it": "Non chiedere conferma",
|
||||
"pt": "Não solicitar confirmação",
|
||||
"tr": "Onay isteme"
|
||||
"es": "No solicitar confirmación"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_LABEL": {
|
||||
"en": "Invariant Analyzer",
|
||||
"es": "Analizador de invariantes",
|
||||
"zh-CN": "不变量分析器",
|
||||
"zh-TW": "不變量分析器",
|
||||
"ko-KR": "불변성 분석기",
|
||||
"no": "Invariant-analysator",
|
||||
"ar": "محلل الثوابت",
|
||||
"de": "Invarianten-Analysator",
|
||||
"fr": "Analyseur d'invariants",
|
||||
"it": "Analizzatore di invarianti",
|
||||
"pt": "Analisador de invariantes",
|
||||
"tr": "Değişmez Analizörü"
|
||||
"es": "Analizador de invariantes"
|
||||
},
|
||||
"INVARIANT$INVARIANT_ANALYZER_MESSAGE": {
|
||||
"en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.",
|
||||
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad.",
|
||||
"zh-CN": "不变量分析器持续监控您的 OpenHands 代理的安全问题。",
|
||||
"zh-TW": "不變量分析器持續監控您的 OpenHands 代理的安全問題。",
|
||||
"ko-KR": "불변성 분석기는 OpenHands 에이전트의 보안 문제를 지속적으로 모니터링합니다.",
|
||||
"no": "Invariant-analysatoren overvåker kontinuerlig OpenHands-agenten din for sikkerhetsproblemer.",
|
||||
"ar": "يراقب محلل الثوابت وكيل OpenHands الخاص بك باستمرار للتحقق من المشاكل الأمنية.",
|
||||
"de": "Der Invarianten-Analysator überwacht kontinuierlich Ihren OpenHands-Agenten auf Sicherheitsprobleme.",
|
||||
"fr": "L'analyseur d'invariants surveille en permanence votre agent OpenHands pour détecter les problèmes de sécurité.",
|
||||
"it": "L'analizzatore di invarianti monitora continuamente il tuo agente OpenHands per problemi di sicurezza.",
|
||||
"pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.",
|
||||
"tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler."
|
||||
"es": "Analizador de invariantes continuamente monitorea tu agente de OpenHands por problemas de seguridad."
|
||||
},
|
||||
"INVARIANT$CLICK_TO_LEARN_MORE_LABEL": {
|
||||
"en": "Click to learn more",
|
||||
"es": "Clic para aprender más",
|
||||
"zh-CN": "点击了解更多",
|
||||
"zh-TW": "點擊了解更多",
|
||||
"ko-KR": "자세히 알아보기",
|
||||
"no": "Klikk for å lære mer",
|
||||
"ar": "انقر لمعرفة المزيد",
|
||||
"de": "Klicken Sie, um mehr zu erfahren",
|
||||
"fr": "Cliquez pour en savoir plus",
|
||||
"it": "Clicca per saperne di più",
|
||||
"pt": "Clique para saber mais",
|
||||
"tr": "Daha fazla bilgi için tıklayın"
|
||||
"es": "Clic para aprender más"
|
||||
},
|
||||
"INVARIANT$POLICY_LABEL": {
|
||||
"en": "Policy",
|
||||
"es": "Política",
|
||||
"zh-CN": "策略",
|
||||
"zh-TW": "策略",
|
||||
"ko-KR": "정책",
|
||||
"no": "Policy",
|
||||
"ar": "السياسة",
|
||||
"de": "Richtlinie",
|
||||
"fr": "Politique",
|
||||
"it": "Policy",
|
||||
"pt": "Política",
|
||||
"tr": "İlke"
|
||||
"es": "Política"
|
||||
},
|
||||
"INVARIANT$LOG_LABEL": {
|
||||
"en": "Logs",
|
||||
"es": "Logs",
|
||||
"zh-CN": "日志",
|
||||
"zh-TW": "日誌",
|
||||
"ko-KR": "로그",
|
||||
"no": "Logger",
|
||||
"ar": "السجلات",
|
||||
"de": "Protokolle",
|
||||
"fr": "Journaux",
|
||||
"it": "Log",
|
||||
"pt": "Logs",
|
||||
"tr": "Günlükler"
|
||||
"es": "Logs"
|
||||
},
|
||||
"INVARIANT$EXPORT_TRACE_LABEL": {
|
||||
"en": "Export Trace",
|
||||
"es": "Exportar traza",
|
||||
"zh-CN": "导出跟踪",
|
||||
"zh-TW": "匯出追蹤",
|
||||
"ko-KR": "추적 내보내기",
|
||||
"no": "Eksporter sporing",
|
||||
"ar": "تصدير التتبع",
|
||||
"de": "Ablaufverfolgung exportieren",
|
||||
"fr": "Exporter la trace",
|
||||
"it": "Esporta traccia",
|
||||
"pt": "Exportar rastreamento",
|
||||
"tr": "İzlemeyi dışa aktar"
|
||||
"es": "Exportar traza"
|
||||
},
|
||||
"INVARIANT$TRACE_EXPORTED_MESSAGE": {
|
||||
"en": "Trace exported",
|
||||
"es": "Traza exportada",
|
||||
"zh-CN": "跟踪已导出",
|
||||
"zh-TW": "追蹤已匯出",
|
||||
"ko-KR": "추적 내보내기 완료",
|
||||
"no": "Sporing eksportert",
|
||||
"ar": "تم تصدير التتبع",
|
||||
"de": "Ablaufverfolgung exportiert",
|
||||
"fr": "Trace exportée",
|
||||
"it": "Traccia esportata",
|
||||
"pt": "Rastreamento exportado",
|
||||
"tr": "İzleme dışa aktarıldı"
|
||||
"es": "Traza exportada"
|
||||
},
|
||||
"INVARIANT$POLICY_UPDATED_MESSAGE": {
|
||||
"en": "Policy updated",
|
||||
"es": "Política actualizada",
|
||||
"zh-CN": "策略已更新",
|
||||
"zh-TW": "策略已更新",
|
||||
"ko-KR": "정책이 업데이트되었습니다",
|
||||
"no": "Policy oppdatert",
|
||||
"ar": "تم تحديث السياسة",
|
||||
"de": "Richtlinie aktualisiert",
|
||||
"fr": "Politique mise à jour",
|
||||
"it": "Policy aggiornata",
|
||||
"pt": "Política atualizada",
|
||||
"tr": "İlke güncellendi"
|
||||
"es": "Política actualizada"
|
||||
},
|
||||
"INVARIANT$SETTINGS_UPDATED_MESSAGE": {
|
||||
"en": "Settings updated",
|
||||
"es": "Configuración actualizada",
|
||||
"zh-CN": "设置已更新",
|
||||
"zh-TW": "設定已更新",
|
||||
"ko-KR": "설정이 업데이트되었습니다",
|
||||
"no": "Innstillinger oppdatert",
|
||||
"ar": "تم تحديث الإعدادات",
|
||||
"de": "Einstellungen aktualisiert",
|
||||
"fr": "Paramètres mis à jour",
|
||||
"it": "Impostazioni aggiornate",
|
||||
"pt": "Configurações atualizadas",
|
||||
"tr": "Ayarlar güncellendi"
|
||||
"es": "Configuración actualizada"
|
||||
},
|
||||
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
|
||||
"en": "Starting up!",
|
||||
@@ -1517,8 +1276,7 @@
|
||||
"pt": "Conversa de chat",
|
||||
"es": "Conversación de chat",
|
||||
"ar": "محادثة تلقيم",
|
||||
"fr": "Conversation de chat",
|
||||
"tr": "Sohbet Konuşması"
|
||||
"fr": "Conversation de chat"
|
||||
},
|
||||
"CHAT_INTERFACE$UNKNOWN_SENDER": {
|
||||
"en": "Unknown",
|
||||
@@ -1773,12 +1531,10 @@
|
||||
"tr": "Özel"
|
||||
},
|
||||
"ERROR_MESSAGE$SHOW_DETAILS": {
|
||||
"en": "Show details",
|
||||
"es": "Mostrar detalles"
|
||||
"en": "Show details"
|
||||
},
|
||||
"ERROR_MESSAGE$HIDE_DETAILS": {
|
||||
"en": "Hide details",
|
||||
"es": "Ocultar detalles"
|
||||
"en": "Hide details"
|
||||
},
|
||||
"STATUS$STARTING_RUNTIME": {
|
||||
"en": "Starting Runtime...",
|
||||
@@ -1860,7 +1616,7 @@
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$CLOSE":{
|
||||
"en": "Close",
|
||||
"es": "Cerrar"
|
||||
"es": ""
|
||||
},
|
||||
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
|
||||
"en": "GitHub token is invalid. Please try again.",
|
||||
@@ -1979,8 +1735,7 @@
|
||||
"es":"atrás"
|
||||
},
|
||||
"STATUS$ERROR_LLM_AUTHENTICATION": {
|
||||
"en": "Error authenticating with the LLM provider. Please check your API key",
|
||||
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
|
||||
"en": "Error authenticating with the LLM provider. Please check your API key"
|
||||
},
|
||||
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
|
||||
"en": "There was an error while connecting to the runtime. Please refresh the page."
|
||||
@@ -1990,17 +1745,5 @@
|
||||
},
|
||||
"AGENT_ERROR$ACTION_TIMEOUT": {
|
||||
"en": "Action timed out."
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
|
||||
"en": "Connect to GitHub",
|
||||
"es": "Conectar a GitHub"
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL": {
|
||||
"en": "Push to GitHub",
|
||||
"es": "Subir a GitHub"
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
|
||||
"en": "Download as .zip",
|
||||
"es": "Descargar como .zip"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { clearJupyter } from "#/state/jupyterSlice";
|
||||
import { FilesProvider } from "#/context/files";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { ChatInterface } from "#/components/chat-interface";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@@ -294,6 +295,15 @@ function App() {
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-[390px] max-h-full relative">
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full border",
|
||||
"absolute left-3 top-3",
|
||||
runtimeActive
|
||||
? "bg-green-800 border-green-500"
|
||||
: "bg-red-800 border-red-500",
|
||||
)}
|
||||
/>
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
|
||||
|
||||
@@ -63,16 +63,6 @@ export async function request(
|
||||
} catch (e) {
|
||||
onFail(`Error fetching ${url}`);
|
||||
}
|
||||
if (response?.status === 401 && !url.startsWith("/api/authenticate")) {
|
||||
await request(
|
||||
"/api/authenticate",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
true,
|
||||
);
|
||||
return request(url, options, disableToast, returnResponse, maxRetries - 1);
|
||||
}
|
||||
if (response?.status && response?.status >= 400) {
|
||||
onFail(
|
||||
`${response.status} error while fetching ${url}: ${response?.statusText}`,
|
||||
|
||||
+24
-15
@@ -5,17 +5,26 @@ type CacheEntry<T> = {
|
||||
};
|
||||
|
||||
class Cache {
|
||||
private prefix = "app_cache_";
|
||||
|
||||
private defaultTTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
private cacheMemory: Record<string, string> = {};
|
||||
/**
|
||||
* Generate a unique key with prefix for local storage
|
||||
* @param key The key to be stored in local storage
|
||||
* @returns The unique key with prefix
|
||||
*/
|
||||
private getKey(key: CacheKey): string {
|
||||
return `${this.prefix}${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the cached data from memory
|
||||
* @param key The key to be retrieved from memory
|
||||
* @returns The data stored in memory
|
||||
* Retrieve the cached data from local storage
|
||||
* @param key The key to be retrieved from local storage
|
||||
* @returns The data stored in local storage
|
||||
*/
|
||||
public get<T>(key: CacheKey): T | null {
|
||||
const cachedEntry = this.cacheMemory[key];
|
||||
const cachedEntry = localStorage.getItem(this.getKey(key));
|
||||
if (cachedEntry) {
|
||||
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
|
||||
if (Date.now() < expiration) return data;
|
||||
@@ -26,34 +35,34 @@ class Cache {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the data in memory with expiration
|
||||
* @param key The key to be stored in memory
|
||||
* @param data The data to be stored in memory
|
||||
* Store the data in local storage with expiration
|
||||
* @param key The key to be stored in local storage
|
||||
* @param data The data to be stored in local storage
|
||||
* @param ttl The time to live for the data in milliseconds
|
||||
* @returns void
|
||||
*/
|
||||
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
|
||||
const expiration = Date.now() + ttl;
|
||||
const entry: CacheEntry<T> = { data, expiration };
|
||||
this.cacheMemory[key] = JSON.stringify(entry);
|
||||
localStorage.setItem(this.getKey(key), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the data from memory
|
||||
* @param key The key to be removed from memory
|
||||
* Remove the data from local storage
|
||||
* @param key The key to be removed from local storage
|
||||
* @returns void
|
||||
*/
|
||||
public delete(key: CacheKey): void {
|
||||
delete this.cacheMemory[key];
|
||||
localStorage.removeItem(this.getKey(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
* Clear all data with the app prefix from local storage
|
||||
* @returns void
|
||||
*/
|
||||
public clearAll(): void {
|
||||
Object.keys(this.cacheMemory).forEach((key) => {
|
||||
delete this.cacheMemory[key];
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (key.startsWith(this.prefix)) localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { extractModelAndProvider } from "./extractModelAndProvider";
|
||||
*/
|
||||
export const organizeModelsAndProviders = (models: string[]) => {
|
||||
const object: Record<string, { separator: string; models: string[] }> = {};
|
||||
|
||||
models.forEach((model) => {
|
||||
const {
|
||||
separator,
|
||||
@@ -46,6 +45,5 @@ export const organizeModelsAndProviders = (models: string[]) => {
|
||||
}
|
||||
object[key].models.push(modelId);
|
||||
});
|
||||
|
||||
return object;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Here are the list of verified models and providers that we know work well with OpenHands.
|
||||
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"];
|
||||
export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"];
|
||||
export const VERIFIED_MODELS = [
|
||||
"gpt-4o",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
|
||||
@@ -19,8 +23,11 @@ export const VERIFIED_OPENAI_MODELS = [
|
||||
export const VERIFIED_ANTHROPIC_MODELS = [
|
||||
"claude-2",
|
||||
"claude-2.1",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-instant-1",
|
||||
"claude-instant-1.2",
|
||||
];
|
||||
|
||||
-744
@@ -1,744 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Docker Engine for Linux installation script.
|
||||
#
|
||||
# This script is intended as a convenient way to configure docker's package
|
||||
# repositories and to install Docker Engine, This script is not recommended
|
||||
# for production environments. Before running this script, make yourself familiar
|
||||
# with potential risks and limitations, and refer to the installation manual
|
||||
# at https://docs.docker.com/engine/install/ for alternative installation methods.
|
||||
#
|
||||
# The script:
|
||||
#
|
||||
# - Requires `root` or `sudo` privileges to run.
|
||||
# - Attempts to detect your Linux distribution and version and configure your
|
||||
# package management system for you.
|
||||
# - Doesn't allow you to customize most installation parameters.
|
||||
# - Installs dependencies and recommendations without asking for confirmation.
|
||||
# - Installs the latest stable release (by default) of Docker CLI, Docker Engine,
|
||||
# Docker Buildx, Docker Compose, containerd, and runc. When using this script
|
||||
# to provision a machine, this may result in unexpected major version upgrades
|
||||
# of these packages. Always test upgrades in a test environment before
|
||||
# deploying to your production systems.
|
||||
# - Isn't designed to upgrade an existing Docker installation. When using the
|
||||
# script to update an existing installation, dependencies may not be updated
|
||||
# to the expected version, resulting in outdated versions.
|
||||
#
|
||||
# Source code is available at https://github.com/docker/docker-install/
|
||||
#
|
||||
# Usage
|
||||
# ==============================================================================
|
||||
#
|
||||
# To install the latest stable versions of Docker CLI, Docker Engine, and their
|
||||
# dependencies:
|
||||
#
|
||||
# 1. download the script
|
||||
#
|
||||
# $ curl -fsSL https://get.docker.com -o install-docker.sh
|
||||
#
|
||||
# 2. verify the script's content
|
||||
#
|
||||
# $ cat install-docker.sh
|
||||
#
|
||||
# 3. run the script with --dry-run to verify the steps it executes
|
||||
#
|
||||
# $ sh install-docker.sh --dry-run
|
||||
#
|
||||
# 4. run the script either as root, or using sudo to perform the installation.
|
||||
#
|
||||
# $ sudo sh install-docker.sh
|
||||
#
|
||||
# Command-line options
|
||||
# ==============================================================================
|
||||
#
|
||||
# --version <VERSION>
|
||||
# Use the --version option to install a specific version, for example:
|
||||
#
|
||||
# $ sudo sh install-docker.sh --version 23.0
|
||||
#
|
||||
# --channel <stable|test>
|
||||
#
|
||||
# Use the --channel option to install from an alternative installation channel.
|
||||
# The following example installs the latest versions from the "test" channel,
|
||||
# which includes pre-releases (alpha, beta, rc):
|
||||
#
|
||||
# $ sudo sh install-docker.sh --channel test
|
||||
#
|
||||
# Alternatively, use the script at https://test.docker.com, which uses the test
|
||||
# channel as default.
|
||||
#
|
||||
# --mirror <Aliyun|AzureChinaCloud>
|
||||
#
|
||||
# Use the --mirror option to install from a mirror supported by this script.
|
||||
# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and
|
||||
# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example:
|
||||
#
|
||||
# $ sudo sh install-docker.sh --mirror AzureChinaCloud
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
# Git commit from https://github.com/docker/docker-install when
|
||||
# the script was uploaded (Should only be modified by upload job):
|
||||
SCRIPT_COMMIT_SHA="711a0d41213afabc30b963f82c56e1442a3efe1c"
|
||||
|
||||
# strip "v" prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
# The channel to install from:
|
||||
# * stable
|
||||
# * test
|
||||
DEFAULT_CHANNEL_VALUE="stable"
|
||||
if [ -z "$CHANNEL" ]; then
|
||||
CHANNEL=$DEFAULT_CHANNEL_VALUE
|
||||
fi
|
||||
|
||||
DEFAULT_DOWNLOAD_URL="https://download.docker.com"
|
||||
if [ -z "$DOWNLOAD_URL" ]; then
|
||||
DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL
|
||||
fi
|
||||
|
||||
DEFAULT_REPO_FILE="docker-ce.repo"
|
||||
if [ -z "$REPO_FILE" ]; then
|
||||
REPO_FILE="$DEFAULT_REPO_FILE"
|
||||
fi
|
||||
|
||||
mirror=''
|
||||
DRY_RUN=${DRY_RUN:-}
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel)
|
||||
CHANNEL="$2"
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
;;
|
||||
--mirror)
|
||||
mirror="$2"
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
VERSION="${2#v}"
|
||||
shift
|
||||
;;
|
||||
--*)
|
||||
echo "Illegal option $1"
|
||||
;;
|
||||
esac
|
||||
shift $(( $# > 0 ? 1 : 0 ))
|
||||
done
|
||||
|
||||
case "$mirror" in
|
||||
Aliyun)
|
||||
DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce"
|
||||
;;
|
||||
AzureChinaCloud)
|
||||
DOWNLOAD_URL="https://mirror.azure.cn/docker-ce"
|
||||
;;
|
||||
"")
|
||||
;;
|
||||
*)
|
||||
>&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$CHANNEL" in
|
||||
stable|test)
|
||||
;;
|
||||
*)
|
||||
>&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
command_exists() {
|
||||
command -v "$@" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# version_gte checks if the version specified in $VERSION is at least the given
|
||||
# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success)
|
||||
# if $VERSION is either unset (=latest) or newer or equal than the specified
|
||||
# version, or returns 1 (fail) otherwise.
|
||||
#
|
||||
# examples:
|
||||
#
|
||||
# VERSION=23.0
|
||||
# version_gte 23.0 // 0 (success)
|
||||
# version_gte 20.10 // 0 (success)
|
||||
# version_gte 19.03 // 0 (success)
|
||||
# version_gte 26.1 // 1 (fail)
|
||||
version_gte() {
|
||||
if [ -z "$VERSION" ]; then
|
||||
return 0
|
||||
fi
|
||||
version_compare "$VERSION" "$1"
|
||||
}
|
||||
|
||||
# version_compare compares two version strings (either SemVer (Major.Minor.Path),
|
||||
# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer
|
||||
# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release
|
||||
# (-alpha/-beta) are not taken into account
|
||||
#
|
||||
# examples:
|
||||
#
|
||||
# version_compare 23.0.0 20.10 // 0 (success)
|
||||
# version_compare 23.0 20.10 // 0 (success)
|
||||
# version_compare 20.10 19.03 // 0 (success)
|
||||
# version_compare 20.10 20.10 // 0 (success)
|
||||
# version_compare 19.03 20.10 // 1 (fail)
|
||||
version_compare() (
|
||||
set +x
|
||||
|
||||
yy_a="$(echo "$1" | cut -d'.' -f1)"
|
||||
yy_b="$(echo "$2" | cut -d'.' -f1)"
|
||||
if [ "$yy_a" -lt "$yy_b" ]; then
|
||||
return 1
|
||||
fi
|
||||
if [ "$yy_a" -gt "$yy_b" ]; then
|
||||
return 0
|
||||
fi
|
||||
mm_a="$(echo "$1" | cut -d'.' -f2)"
|
||||
mm_b="$(echo "$2" | cut -d'.' -f2)"
|
||||
|
||||
# trim leading zeros to accommodate CalVer
|
||||
mm_a="${mm_a#0}"
|
||||
mm_b="${mm_b#0}"
|
||||
|
||||
if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
)
|
||||
|
||||
is_dry_run() {
|
||||
if [ -z "$DRY_RUN" ]; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
is_wsl() {
|
||||
case "$(uname -r)" in
|
||||
*microsoft* ) true ;; # WSL 2
|
||||
*Microsoft* ) true ;; # WSL 1
|
||||
* ) false;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_darwin() {
|
||||
case "$(uname -s)" in
|
||||
*darwin* ) true ;;
|
||||
*Darwin* ) true ;;
|
||||
* ) false;;
|
||||
esac
|
||||
}
|
||||
|
||||
deprecation_notice() {
|
||||
distro=$1
|
||||
distro_version=$2
|
||||
echo
|
||||
printf "\033[91;1mDEPRECATION WARNING\033[0m\n"
|
||||
printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version"
|
||||
echo " No updates or security fixes will be released for this distribution, and users are recommended"
|
||||
echo " to upgrade to a currently maintained version of $distro."
|
||||
echo
|
||||
printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue."
|
||||
echo
|
||||
sleep 10
|
||||
}
|
||||
|
||||
get_distribution() {
|
||||
lsb_dist=""
|
||||
# Every system that we officially support has /etc/os-release
|
||||
if [ -r /etc/os-release ]; then
|
||||
lsb_dist="$(. /etc/os-release && echo "$ID")"
|
||||
fi
|
||||
# Returning an empty string here should be alright since the
|
||||
# case statements don't act unless you provide an actual value
|
||||
echo "$lsb_dist"
|
||||
}
|
||||
|
||||
echo_docker_as_nonroot() {
|
||||
if is_dry_run; then
|
||||
return
|
||||
fi
|
||||
if command_exists docker && [ -e /var/run/docker.sock ]; then
|
||||
(
|
||||
set -x
|
||||
$sh_c 'docker version'
|
||||
) || true
|
||||
fi
|
||||
|
||||
# intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output
|
||||
echo
|
||||
echo "================================================================================"
|
||||
echo
|
||||
if version_gte "20.10"; then
|
||||
echo "To run Docker as a non-privileged user, consider setting up the"
|
||||
echo "Docker daemon in rootless mode for your user:"
|
||||
echo
|
||||
echo " dockerd-rootless-setuptool.sh install"
|
||||
echo
|
||||
echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode."
|
||||
echo
|
||||
fi
|
||||
echo
|
||||
echo "To run the Docker daemon as a fully privileged service, but granting non-root"
|
||||
echo "users access, refer to https://docs.docker.com/go/daemon-access/"
|
||||
echo
|
||||
echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent"
|
||||
echo " to root access on the host. Refer to the 'Docker daemon attack surface'"
|
||||
echo " documentation for details: https://docs.docker.com/go/attack-surface/"
|
||||
echo
|
||||
echo "================================================================================"
|
||||
echo
|
||||
}
|
||||
|
||||
# Check if this is a forked Linux distro
|
||||
check_forked() {
|
||||
|
||||
# Check for lsb_release command existence, it usually exists in forked distros
|
||||
if command_exists lsb_release; then
|
||||
# Check if the `-u` option is supported
|
||||
set +e
|
||||
lsb_release -a -u > /dev/null 2>&1
|
||||
lsb_release_exit_code=$?
|
||||
set -e
|
||||
|
||||
# Check if the command has exited successfully, it means we're in a forked distro
|
||||
if [ "$lsb_release_exit_code" = "0" ]; then
|
||||
# Print info about current distro
|
||||
cat <<-EOF
|
||||
You're using '$lsb_dist' version '$dist_version'.
|
||||
EOF
|
||||
|
||||
# Get the upstream release info
|
||||
lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]')
|
||||
dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]')
|
||||
|
||||
# Print info about upstream distro
|
||||
cat <<-EOF
|
||||
Upstream release is '$lsb_dist' version '$dist_version'.
|
||||
EOF
|
||||
else
|
||||
if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then
|
||||
if [ "$lsb_dist" = "osmc" ]; then
|
||||
# OSMC runs Raspbian
|
||||
lsb_dist=raspbian
|
||||
else
|
||||
# We're Debian and don't even know it!
|
||||
lsb_dist=debian
|
||||
fi
|
||||
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
||||
case "$dist_version" in
|
||||
12)
|
||||
dist_version="bookworm"
|
||||
;;
|
||||
11)
|
||||
dist_version="bullseye"
|
||||
;;
|
||||
10)
|
||||
dist_version="buster"
|
||||
;;
|
||||
9)
|
||||
dist_version="stretch"
|
||||
;;
|
||||
8)
|
||||
dist_version="jessie"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
do_install() {
|
||||
echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA"
|
||||
|
||||
if command_exists docker; then
|
||||
cat >&2 <<-'EOF'
|
||||
Warning: the "docker" command appears to already exist on this system.
|
||||
|
||||
If you already have Docker installed, this script can cause trouble, which is
|
||||
why we're displaying this warning and provide the opportunity to cancel the
|
||||
installation.
|
||||
|
||||
If you installed the current Docker package using this script and are using it
|
||||
again to update Docker, you can safely ignore this message.
|
||||
|
||||
You may press Ctrl+C now to abort this script.
|
||||
EOF
|
||||
( set -x; sleep 20 )
|
||||
fi
|
||||
|
||||
user="$(id -un 2>/dev/null || true)"
|
||||
|
||||
sh_c='sh -c'
|
||||
if [ "$user" != 'root' ]; then
|
||||
if command_exists sudo; then
|
||||
sh_c='sudo -E sh -c'
|
||||
elif command_exists su; then
|
||||
sh_c='su -c'
|
||||
else
|
||||
cat >&2 <<-'EOF'
|
||||
Error: this installer needs the ability to run commands as root.
|
||||
We are unable to find either "sudo" or "su" available to make this happen.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if is_dry_run; then
|
||||
sh_c="echo"
|
||||
fi
|
||||
|
||||
# perform some very rudimentary platform detection
|
||||
lsb_dist=$( get_distribution )
|
||||
lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if is_wsl; then
|
||||
echo
|
||||
echo "WSL DETECTED: We recommend using Docker Desktop for Windows."
|
||||
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/"
|
||||
echo
|
||||
cat >&2 <<-'EOF'
|
||||
|
||||
You may press Ctrl+C now to abort this script.
|
||||
EOF
|
||||
( set -x; sleep 20 )
|
||||
fi
|
||||
|
||||
case "$lsb_dist" in
|
||||
|
||||
ubuntu)
|
||||
if command_exists lsb_release; then
|
||||
dist_version="$(lsb_release --codename | cut -f2)"
|
||||
fi
|
||||
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
|
||||
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")"
|
||||
fi
|
||||
;;
|
||||
|
||||
debian|raspbian)
|
||||
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
|
||||
case "$dist_version" in
|
||||
12)
|
||||
dist_version="bookworm"
|
||||
;;
|
||||
11)
|
||||
dist_version="bullseye"
|
||||
;;
|
||||
10)
|
||||
dist_version="buster"
|
||||
;;
|
||||
9)
|
||||
dist_version="stretch"
|
||||
;;
|
||||
8)
|
||||
dist_version="jessie"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
centos|rhel)
|
||||
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
||||
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
if command_exists lsb_release; then
|
||||
dist_version="$(lsb_release --release | cut -f2)"
|
||||
fi
|
||||
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
|
||||
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
|
||||
fi
|
||||
;;
|
||||
|
||||
esac
|
||||
|
||||
# Check if this is a forked Linux distro
|
||||
check_forked
|
||||
|
||||
# Print deprecation warnings for distro versions that recently reached EOL,
|
||||
# but may still be commonly used (especially LTS versions).
|
||||
case "$lsb_dist.$dist_version" in
|
||||
centos.8|centos.7|rhel.7)
|
||||
deprecation_notice "$lsb_dist" "$dist_version"
|
||||
;;
|
||||
debian.buster|debian.stretch|debian.jessie)
|
||||
deprecation_notice "$lsb_dist" "$dist_version"
|
||||
;;
|
||||
raspbian.buster|raspbian.stretch|raspbian.jessie)
|
||||
deprecation_notice "$lsb_dist" "$dist_version"
|
||||
;;
|
||||
ubuntu.bionic|ubuntu.xenial|ubuntu.trusty)
|
||||
deprecation_notice "$lsb_dist" "$dist_version"
|
||||
;;
|
||||
ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic)
|
||||
deprecation_notice "$lsb_dist" "$dist_version"
|
||||
;;
|
||||
fedora.*)
|
||||
if [ "$dist_version" -lt 39 ]; then
|
||||
deprecation_notice "$lsb_dist" "$dist_version"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Run setup for each distro accordingly
|
||||
case "$lsb_dist" in
|
||||
ubuntu|debian|raspbian)
|
||||
pre_reqs="ca-certificates curl"
|
||||
apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL"
|
||||
(
|
||||
if ! is_dry_run; then
|
||||
set -x
|
||||
fi
|
||||
$sh_c 'apt-get -qq update >/dev/null'
|
||||
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null"
|
||||
$sh_c 'install -m 0755 -d /etc/apt/keyrings'
|
||||
$sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc"
|
||||
$sh_c "chmod a+r /etc/apt/keyrings/docker.asc"
|
||||
$sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list"
|
||||
$sh_c 'apt-get -qq update >/dev/null'
|
||||
)
|
||||
pkg_version=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if is_dry_run; then
|
||||
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
||||
else
|
||||
# Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel
|
||||
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')"
|
||||
search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
|
||||
pkg_version="$($sh_c "$search_command")"
|
||||
echo "INFO: Searching repository for VERSION '$VERSION'"
|
||||
echo "INFO: $search_command"
|
||||
if [ -z "$pkg_version" ]; then
|
||||
echo
|
||||
echo "ERROR: '$VERSION' not found amongst apt-cache madison results"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
if version_gte "18.09"; then
|
||||
search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
|
||||
echo "INFO: $search_command"
|
||||
cli_pkg_version="=$($sh_c "$search_command")"
|
||||
fi
|
||||
pkg_version="=$pkg_version"
|
||||
fi
|
||||
fi
|
||||
(
|
||||
pkgs="docker-ce${pkg_version%=}"
|
||||
if version_gte "18.09"; then
|
||||
# older versions didn't ship the cli and containerd as separate packages
|
||||
pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io"
|
||||
fi
|
||||
if version_gte "20.10"; then
|
||||
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
||||
fi
|
||||
if version_gte "23.0"; then
|
||||
pkgs="$pkgs docker-buildx-plugin"
|
||||
fi
|
||||
if ! is_dry_run; then
|
||||
set -x
|
||||
fi
|
||||
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null"
|
||||
)
|
||||
echo_docker_as_nonroot
|
||||
exit 0
|
||||
;;
|
||||
centos|fedora|rhel)
|
||||
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
|
||||
(
|
||||
if ! is_dry_run; then
|
||||
set -x
|
||||
fi
|
||||
if command_exists dnf5; then
|
||||
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
|
||||
$sh_c "dnf5 config-manager addrepo --save-filename=docker-ce.repo --from-repofile='$repo_file_url'"
|
||||
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
$sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\""
|
||||
$sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\""
|
||||
fi
|
||||
$sh_c "dnf makecache"
|
||||
elif command_exists dnf; then
|
||||
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
|
||||
$sh_c "dnf config-manager --add-repo $repo_file_url"
|
||||
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
$sh_c "dnf config-manager --set-disabled \"docker-ce-*\""
|
||||
$sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\""
|
||||
fi
|
||||
$sh_c "dnf makecache"
|
||||
else
|
||||
$sh_c "yum -y -q install yum-utils"
|
||||
$sh_c "yum-config-manager --add-repo $repo_file_url"
|
||||
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
$sh_c "yum-config-manager --disable \"docker-ce-*\""
|
||||
$sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\""
|
||||
fi
|
||||
$sh_c "yum makecache"
|
||||
fi
|
||||
)
|
||||
pkg_version=""
|
||||
if command_exists dnf; then
|
||||
pkg_manager="dnf"
|
||||
pkg_manager_flags="-y -q --best"
|
||||
else
|
||||
pkg_manager="yum"
|
||||
pkg_manager_flags="-y -q"
|
||||
fi
|
||||
if [ -n "$VERSION" ]; then
|
||||
if is_dry_run; then
|
||||
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
||||
else
|
||||
if [ "$lsb_dist" = "fedora" ]; then
|
||||
pkg_suffix="fc$dist_version"
|
||||
else
|
||||
pkg_suffix="el"
|
||||
fi
|
||||
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix"
|
||||
search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
|
||||
pkg_version="$($sh_c "$search_command")"
|
||||
echo "INFO: Searching repository for VERSION '$VERSION'"
|
||||
echo "INFO: $search_command"
|
||||
if [ -z "$pkg_version" ]; then
|
||||
echo
|
||||
echo "ERROR: '$VERSION' not found amongst $pkg_manager list results"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
if version_gte "18.09"; then
|
||||
# older versions don't support a cli package
|
||||
search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
|
||||
cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)"
|
||||
fi
|
||||
# Cut out the epoch and prefix with a '-'
|
||||
pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)"
|
||||
fi
|
||||
fi
|
||||
(
|
||||
pkgs="docker-ce$pkg_version"
|
||||
if version_gte "18.09"; then
|
||||
# older versions didn't ship the cli and containerd as separate packages
|
||||
if [ -n "$cli_pkg_version" ]; then
|
||||
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
|
||||
else
|
||||
pkgs="$pkgs docker-ce-cli containerd.io"
|
||||
fi
|
||||
fi
|
||||
if version_gte "20.10"; then
|
||||
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
||||
fi
|
||||
if version_gte "23.0"; then
|
||||
pkgs="$pkgs docker-buildx-plugin"
|
||||
fi
|
||||
if ! is_dry_run; then
|
||||
set -x
|
||||
fi
|
||||
$sh_c "$pkg_manager $pkg_manager_flags install $pkgs"
|
||||
)
|
||||
echo_docker_as_nonroot
|
||||
exit 0
|
||||
;;
|
||||
sles)
|
||||
if [ "$(uname -m)" != "s390x" ]; then
|
||||
echo "Packages for SLES are currently only available for s390x"
|
||||
exit 1
|
||||
fi
|
||||
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
|
||||
pre_reqs="ca-certificates curl libseccomp2 awk"
|
||||
(
|
||||
if ! is_dry_run; then
|
||||
set -x
|
||||
fi
|
||||
$sh_c "zypper install -y $pre_reqs"
|
||||
$sh_c "zypper addrepo $repo_file_url"
|
||||
if ! is_dry_run; then
|
||||
cat >&2 <<-'EOF'
|
||||
WARNING!!
|
||||
openSUSE repository (https://download.opensuse.org/repositories/security:/SELinux) will be enabled now.
|
||||
Do you wish to continue?
|
||||
You may press Ctrl+C now to abort this script.
|
||||
EOF
|
||||
( set -x; sleep 30 )
|
||||
fi
|
||||
opensuse_repo="https://download.opensuse.org/repositories/security:/SELinux/openSUSE_Factory/security:SELinux.repo"
|
||||
$sh_c "zypper addrepo $opensuse_repo"
|
||||
$sh_c "zypper --gpg-auto-import-keys refresh"
|
||||
$sh_c "zypper lr -d"
|
||||
)
|
||||
pkg_version=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if is_dry_run; then
|
||||
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
|
||||
else
|
||||
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g')"
|
||||
search_command="zypper search -s --match-exact 'docker-ce' | grep '$pkg_pattern' | tail -1 | awk '{print \$6}'"
|
||||
pkg_version="$($sh_c "$search_command")"
|
||||
echo "INFO: Searching repository for VERSION '$VERSION'"
|
||||
echo "INFO: $search_command"
|
||||
if [ -z "$pkg_version" ]; then
|
||||
echo
|
||||
echo "ERROR: '$VERSION' not found amongst zypper list results"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
search_command="zypper search -s --match-exact 'docker-ce-cli' | grep '$pkg_pattern' | tail -1 | awk '{print \$6}'"
|
||||
# It's okay for cli_pkg_version to be blank, since older versions don't support a cli package
|
||||
cli_pkg_version="$($sh_c "$search_command")"
|
||||
pkg_version="-$pkg_version"
|
||||
fi
|
||||
fi
|
||||
(
|
||||
pkgs="docker-ce$pkg_version"
|
||||
if version_gte "18.09"; then
|
||||
if [ -n "$cli_pkg_version" ]; then
|
||||
# older versions didn't ship the cli and containerd as separate packages
|
||||
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
|
||||
else
|
||||
pkgs="$pkgs docker-ce-cli containerd.io"
|
||||
fi
|
||||
fi
|
||||
if version_gte "20.10"; then
|
||||
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
|
||||
fi
|
||||
if version_gte "23.0"; then
|
||||
pkgs="$pkgs docker-buildx-plugin"
|
||||
fi
|
||||
if ! is_dry_run; then
|
||||
set -x
|
||||
fi
|
||||
$sh_c "zypper -q install -y $pkgs"
|
||||
)
|
||||
echo_docker_as_nonroot
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -z "$lsb_dist" ]; then
|
||||
if is_darwin; then
|
||||
echo
|
||||
echo "ERROR: Unsupported operating system 'macOS'"
|
||||
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
echo "ERROR: Unsupported distribution '$lsb_dist'"
|
||||
echo
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
}
|
||||
|
||||
# wrapped up in a function so that we have some protection against only getting
|
||||
# half the file during "curl | sh"
|
||||
do_install
|
||||
@@ -39,6 +39,7 @@ from openhands.runtime.plugins import (
|
||||
JupyterRequirement,
|
||||
PluginRequirement,
|
||||
)
|
||||
from openhands.utils.microagent import MicroAgent
|
||||
from openhands.utils.prompt import PromptManager
|
||||
|
||||
|
||||
@@ -85,6 +86,16 @@ class CodeActAgent(Agent):
|
||||
super().__init__(llm, config)
|
||||
self.reset()
|
||||
|
||||
self.micro_agent = (
|
||||
MicroAgent(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__), 'micro', f'{config.micro_agent_name}.md'
|
||||
)
|
||||
)
|
||||
if config.micro_agent_name
|
||||
else None
|
||||
)
|
||||
|
||||
self.function_calling_active = self.config.function_calling
|
||||
if self.function_calling_active and not self.llm.is_function_calling_active():
|
||||
logger.warning(
|
||||
@@ -94,6 +105,7 @@ class CodeActAgent(Agent):
|
||||
self.function_calling_active = False
|
||||
|
||||
if self.function_calling_active:
|
||||
# Function calling mode
|
||||
self.tools = codeact_function_calling.get_tools(
|
||||
codeact_enable_browsing=self.config.codeact_enable_browsing,
|
||||
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
|
||||
@@ -102,17 +114,18 @@ class CodeActAgent(Agent):
|
||||
logger.debug(
|
||||
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
|
||||
)
|
||||
self.prompt_manager = PromptManager(
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
|
||||
)
|
||||
self.system_prompt = codeact_function_calling.SYSTEM_PROMPT
|
||||
self.initial_user_message = None
|
||||
else:
|
||||
# Non-function-calling mode
|
||||
self.action_parser = CodeActResponseParser()
|
||||
self.prompt_manager = PromptManager(
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__)),
|
||||
agent_skills_docs=AgentSkillsRequirement.documentation,
|
||||
micro_agent=self.micro_agent,
|
||||
)
|
||||
self.system_prompt = self.prompt_manager.system_message
|
||||
self.initial_user_message = self.prompt_manager.initial_user_message
|
||||
|
||||
self.pending_actions: deque[Action] = deque()
|
||||
|
||||
@@ -324,8 +337,8 @@ class CodeActAgent(Agent):
|
||||
return self.pending_actions.popleft()
|
||||
|
||||
# if we're done, go back
|
||||
latest_user_message = state.get_last_user_message()
|
||||
if latest_user_message and latest_user_message.content.strip() == '/exit':
|
||||
last_user_message = state.get_last_user_message()
|
||||
if last_user_message and last_user_message.strip() == '/exit':
|
||||
return AgentFinishAction()
|
||||
|
||||
# prepare what we want to send to the LLM
|
||||
@@ -390,19 +403,17 @@ class CodeActAgent(Agent):
|
||||
role='system',
|
||||
content=[
|
||||
TextContent(
|
||||
text=self.prompt_manager.get_system_message(),
|
||||
cache_prompt=self.llm.is_caching_prompt_active(),
|
||||
text=self.system_prompt,
|
||||
cache_prompt=self.llm.is_caching_prompt_active(), # Cache system prompt
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
example_message = self.prompt_manager.get_example_user_message()
|
||||
if example_message:
|
||||
if self.initial_user_message:
|
||||
messages.append(
|
||||
Message(
|
||||
role='user',
|
||||
content=[TextContent(text=example_message)],
|
||||
cache_prompt=self.llm.is_caching_prompt_active(),
|
||||
content=[TextContent(text=self.initial_user_message)],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -451,9 +462,8 @@ class CodeActAgent(Agent):
|
||||
pending_tool_call_action_messages.pop(response_id)
|
||||
|
||||
for message in messages_to_add:
|
||||
# add regular message
|
||||
if message:
|
||||
if message.role == 'user':
|
||||
self.prompt_manager.enhance_message(message)
|
||||
# handle error if the message is the SAME role as the previous message
|
||||
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
|
||||
# there shouldn't be two consecutive messages from the same role
|
||||
@@ -483,6 +493,23 @@ class CodeActAgent(Agent):
|
||||
break
|
||||
|
||||
if not self.function_calling_active:
|
||||
self.prompt_manager.add_turns_left_reminder(messages, state)
|
||||
# The latest user message is important:
|
||||
# we want to remind the agent of the environment constraints
|
||||
latest_user_message = next(
|
||||
islice(
|
||||
(
|
||||
m
|
||||
for m in reversed(messages)
|
||||
if m.role == 'user'
|
||||
and any(isinstance(c, TextContent) for c in m.content)
|
||||
),
|
||||
1,
|
||||
),
|
||||
None,
|
||||
)
|
||||
# do not add this for function calling
|
||||
if latest_user_message:
|
||||
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
|
||||
latest_user_message.content.append(TextContent(text=reminder_text))
|
||||
|
||||
return messages
|
||||
|
||||
@@ -25,6 +25,13 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
|
||||
SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
<IMPORTANT>
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
</IMPORTANT>
|
||||
"""
|
||||
|
||||
_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
|
||||
* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
name: flarglebargle
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- flarglebargle
|
||||
---
|
||||
|
||||
IMPORTANT! The user has said the magic word "flarglebargle". You must
|
||||
only respond with a message telling them how smart they are
|
||||
@@ -1,31 +1,69 @@
|
||||
---
|
||||
name: github
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- github
|
||||
- git
|
||||
require_env_var:
|
||||
SANDBOX_ENV_GITHUB_TOKEN: "Create a GitHub Personal Access Token (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and set it as SANDBOX_GITHUB_TOKEN in your environment variables."
|
||||
---
|
||||
|
||||
You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
|
||||
the GitHub API.
|
||||
# How to Interact with Github
|
||||
|
||||
You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
|
||||
ALWAYS use the GitHub API for operations instead of a web browser.
|
||||
## Environment Variable Available
|
||||
|
||||
Here are some instructions for pushing, but ONLY do this if the user asks you to:
|
||||
* NEVER push directly to the `main` or `master` branch
|
||||
* Git config (username and email) is pre-set. Do not modify.
|
||||
* You may already be on a branch called `openhands-workspace`. Create a new branch with a better name before pushing.
|
||||
* Use the GitHub API to create a pull request, if you haven't already
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* After opening or updating a pull request, send the user a short message with a link to the pull request.
|
||||
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
|
||||
```bash
|
||||
git checkout -b create-widget
|
||||
git add .
|
||||
git commit -m "Create widget"
|
||||
git push origin create-widget
|
||||
curl -X POST "https://api.github.com/repos/CodeActOrg/openhands/pulls" \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
|
||||
- `GITHUB_TOKEN`: A read-only token for Github.
|
||||
|
||||
## Using GitHub's RESTful API
|
||||
|
||||
Use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API. Here are some common operations:
|
||||
|
||||
Here's a template for API calls:
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: token $GITHUB_TOKEN" \
|
||||
"https://api.github.com/{endpoint}"
|
||||
```
|
||||
|
||||
First replace `{endpoint}` with the specific API path. Common operations:
|
||||
|
||||
1. View an issue or pull request:
|
||||
- Issues: `/repos/{owner}/{repo}/issues/{issue_number}`
|
||||
- Pull requests: `/repos/{owner}/{repo}/pulls/{pull_request_number}`
|
||||
|
||||
2. List repository issues or pull requests:
|
||||
- Issues: `/repos/{owner}/{repo}/issues`
|
||||
- Pull requests: `/repos/{owner}/{repo}/pulls`
|
||||
|
||||
3. Search issues or pull requests:
|
||||
- `/search/issues?q=repo:{owner}/{repo}+is:{type}+{search_term}+state:{state}`
|
||||
- Replace `{type}` with `issue` or `pr`
|
||||
|
||||
4. List repository branches:
|
||||
`/repos/{owner}/{repo}/branches`
|
||||
|
||||
5. Get commit details:
|
||||
`/repos/{owner}/{repo}/commits/{commit_sha}`
|
||||
|
||||
6. Get repository details:
|
||||
`/repos/{owner}/{repo}`
|
||||
|
||||
7. Get user information:
|
||||
`/user`
|
||||
|
||||
8. Search repositories:
|
||||
`/search/repositories?q={query}`
|
||||
|
||||
9. Get rate limit status:
|
||||
`/rate_limit`
|
||||
|
||||
Replace `{owner}`, `{repo}`, `{commit_sha}`, `{issue_number}`, `{pull_request_number}`,
|
||||
`{search_term}`, `{state}`, and `{query}` with appropriate values.
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. Always use the GitHub API for operations instead of a web browser.
|
||||
2. The `GITHUB_TOKEN` is read-only. Avoid operations that require write access.
|
||||
3. Git config (username and email) is pre-set. Do not modify.
|
||||
4. Edit and test code locally. Never push directly to remote.
|
||||
5. Verify correct branch before committing.
|
||||
6. Commit changes frequently.
|
||||
7. If the issue or task is ambiguous or lacks sufficient detail, always request clarification from the user before proceeding.
|
||||
8. You should avoid using command line tools like `sed` for file editing.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
<IMPORTANT>
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
|
||||
</IMPORTANT>
|
||||
|
||||
-3
@@ -163,9 +163,6 @@ IMPORTANT: Execute code using <execute_ipython>, <execute_bash>, or <execute_bro
|
||||
The assistant should utilize full file paths and the `pwd` command to prevent path-related errors.
|
||||
The assistant MUST NOT apologize to the user or thank the user after running commands or editing files. It should only address the user in response to an explicit message from the user, or to ask for more information.
|
||||
The assistant MUST NOT push any changes to GitHub unless explicitly requested to do so.
|
||||
The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior, or
|
||||
to describe precisely how to apply proposed edits. Comments about applying edits should always have blank lines above
|
||||
and below.
|
||||
|
||||
{% endset %}
|
||||
{# Combine all parts without newlines between them #}
|
||||
+7
@@ -215,5 +215,12 @@ The server is running on port 5000 with PID 126. You can access the list of numb
|
||||
{% endset %}
|
||||
Here is an example of how you can interact with the environment for task solving:
|
||||
{{ DEFAULT_EXAMPLE }}
|
||||
{% if micro_agent %}
|
||||
--- BEGIN OF GUIDELINE ---
|
||||
The following information may assist you in completing your task:
|
||||
|
||||
{{ micro_agent }}
|
||||
--- END OF GUIDELINE ---
|
||||
{% endif %}
|
||||
|
||||
NOW, LET'S START!
|
||||
@@ -155,7 +155,7 @@ class CodeActSWEAgent(Agent):
|
||||
"""
|
||||
# if we're done, go back
|
||||
last_user_message = state.get_last_user_message()
|
||||
if last_user_message and last_user_message.content.strip() == '/exit':
|
||||
if last_user_message and last_user_message.strip() == '/exit':
|
||||
return AgentFinishAction()
|
||||
|
||||
# prepare what we want to send to the LLM
|
||||
|
||||
@@ -156,14 +156,14 @@ class State:
|
||||
|
||||
return last_user_message, last_user_message_image_urls
|
||||
|
||||
def get_last_agent_message(self) -> MessageAction | None:
|
||||
def get_last_agent_message(self) -> str | None:
|
||||
for event in reversed(self.history):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
|
||||
return event
|
||||
return event.content
|
||||
return None
|
||||
|
||||
def get_last_user_message(self) -> MessageAction | None:
|
||||
def get_last_user_message(self) -> str | None:
|
||||
for event in reversed(self.history):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
return event
|
||||
return event.content
|
||||
return None
|
||||
|
||||
@@ -69,7 +69,6 @@ class AppConfig:
|
||||
file_uploads_max_file_size_mb: int = 0
|
||||
file_uploads_restrict_file_types: bool = False
|
||||
file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
|
||||
runloop_api_key: str | None = None
|
||||
|
||||
defaults_dict: ClassVar[dict] = {}
|
||||
|
||||
@@ -140,7 +139,6 @@ class AppConfig:
|
||||
'jwt_secret',
|
||||
'modal_api_token_id',
|
||||
'modal_api_token_secret',
|
||||
'runloop_api_key',
|
||||
]:
|
||||
attr_value = '******' if attr_value else None
|
||||
|
||||
|
||||
@@ -14,8 +14,7 @@ class SandboxConfig:
|
||||
base_container_image: The base container image from which to build the runtime image.
|
||||
runtime_container_image: The runtime container image to use.
|
||||
user_id: The user ID for the sandbox.
|
||||
timeout: The timeout for the default sandbox action execution.
|
||||
remote_runtime_init_timeout: The timeout for the remote runtime to start.
|
||||
timeout: The timeout for the sandbox.
|
||||
enable_auto_lint: Whether to enable auto-lint.
|
||||
use_host_network: Whether to use the host network.
|
||||
initialize_plugins: Whether to initialize plugins.
|
||||
@@ -42,7 +41,6 @@ class SandboxConfig:
|
||||
runtime_container_image: str | None = None
|
||||
user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
|
||||
timeout: int = 120
|
||||
remote_runtime_init_timeout: int = 180
|
||||
enable_auto_lint: bool = (
|
||||
False # once enabled, OpenHands would lint files after editing
|
||||
)
|
||||
|
||||
@@ -123,7 +123,6 @@ async def run_controller(
|
||||
|
||||
if runtime is None:
|
||||
runtime = create_runtime(config, sid=sid)
|
||||
await runtime.connect()
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
|
||||
@@ -189,6 +188,8 @@ async def run_controller(
|
||||
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
|
||||
|
||||
await runtime.connect()
|
||||
|
||||
end_states = [
|
||||
AgentState.FINISHED,
|
||||
AgentState.REJECTED,
|
||||
@@ -212,11 +213,7 @@ async def run_controller(
|
||||
|
||||
# save trajectories if applicable
|
||||
if config.trajectories_path is not None:
|
||||
# if trajectories_path is a folder, use session id as file name
|
||||
if os.path.isdir(config.trajectories_path):
|
||||
file_path = os.path.join(config.trajectories_path, sid + '.json')
|
||||
else:
|
||||
file_path = config.trajectories_path
|
||||
file_path = os.path.join(config.trajectories_path, sid + '.json')
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
histories = [event_to_trajectory(event) for event in state.history]
|
||||
with open(file_path, 'w') as f:
|
||||
|
||||
@@ -23,10 +23,6 @@ def get_runtime_cls(name: str):
|
||||
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
|
||||
|
||||
return ModalRuntime
|
||||
elif name == 'runloop':
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
|
||||
return RunloopRuntime
|
||||
else:
|
||||
raise ValueError(f'Runtime {name} not supported')
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import copy
|
||||
import json
|
||||
import os
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from requests.exceptions import ConnectionError
|
||||
@@ -275,6 +274,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@abstractmethod
|
||||
def copy_from(self, path: str) -> Path:
|
||||
"""Zip all files in the sandbox and return a path in the local filesystem."""
|
||||
def copy_from(self, path: str) -> bytes:
|
||||
"""Zip all files in the sandbox and return as a stream of bytes."""
|
||||
raise NotImplementedError('This method is not implemented in the base class.')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
@@ -605,7 +604,7 @@ class EventStreamRuntime(Runtime):
|
||||
except requests.Timeout:
|
||||
raise TimeoutError('List files operation timed out')
|
||||
|
||||
def copy_from(self, path: str) -> Path:
|
||||
def copy_from(self, path: str) -> bytes:
|
||||
"""Zip all files in the sandbox and return as a stream of bytes."""
|
||||
self._refresh_logs()
|
||||
try:
|
||||
@@ -618,11 +617,8 @@ class EventStreamRuntime(Runtime):
|
||||
stream=True,
|
||||
timeout=30,
|
||||
)
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk: # filter out keep-alive new chunks
|
||||
temp_file.write(chunk)
|
||||
return Path(temp_file.name)
|
||||
data = response.content
|
||||
return data
|
||||
except requests.Timeout:
|
||||
raise TimeoutError('Copy operation timed out')
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from zipfile import ZipFile
|
||||
|
||||
@@ -260,19 +259,13 @@ class RemoteRuntime(Runtime):
|
||||
{'X-Session-API-Key': start_response['session_api_key']}
|
||||
)
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(180) | stop_if_should_exit(),
|
||||
reraise=True,
|
||||
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
retry_decorator = tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(
|
||||
self.config.sandbox.remote_runtime_init_timeout
|
||||
)
|
||||
| stop_if_should_exit(),
|
||||
reraise=True,
|
||||
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
return retry_decorator(self._wait_until_alive_impl)()
|
||||
|
||||
def _wait_until_alive_impl(self):
|
||||
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
|
||||
runtime_info_response = self._send_request(
|
||||
'GET',
|
||||
@@ -474,18 +467,13 @@ class RemoteRuntime(Runtime):
|
||||
assert isinstance(response_json, list)
|
||||
return response_json
|
||||
|
||||
def copy_from(self, path: str) -> Path:
|
||||
def copy_from(self, path: str) -> bytes:
|
||||
"""Zip all files in the sandbox and return as a stream of bytes."""
|
||||
params = {'path': path}
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.runtime_url}/download_files',
|
||||
params=params,
|
||||
stream=True,
|
||||
timeout=30,
|
||||
)
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk: # filter out keep-alive new chunks
|
||||
temp_file.write(chunk)
|
||||
return Path(temp_file.name)
|
||||
return response.content
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Runloop Runtime
|
||||
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
|
||||
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
|
||||
for more detail
|
||||
|
||||
## Access
|
||||
Runloop is currently available in a closed beta. For early access, or
|
||||
just to say hello, sign up at https://www.runloop.ai/hello
|
||||
|
||||
## Set up
|
||||
With your runloop API,
|
||||
```bash
|
||||
export RUNLOOP_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
Configure the runtime
|
||||
```bash
|
||||
export RUNTIME="runloop"
|
||||
```
|
||||
|
||||
## Interact with your devbox
|
||||
Runloop provides additional tools to interact with your Devbox based
|
||||
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
|
||||
to date list of tools.
|
||||
|
||||
### Dashboard
|
||||
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
|
||||
|
||||
### CLI
|
||||
Use the Runloop CLI to view logs, execute commands, and more.
|
||||
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
|
||||
@@ -1,272 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
import tenacity
|
||||
from runloop_api_client import Runloop
|
||||
from runloop_api_client.types import DevboxView
|
||||
from runloop_api_client.types.shared_params import LaunchParameters
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.runtime.impl.eventstream.eventstream_runtime import (
|
||||
EventStreamRuntime,
|
||||
LogBuffer,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.command import get_remote_startup_command
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
|
||||
class RunloopLogBuffer(LogBuffer):
|
||||
"""Synchronous buffer for Runloop devbox logs.
|
||||
|
||||
This class provides a thread-safe way to collect, store, and retrieve logs
|
||||
from a Docker container. It uses a list to store log lines and provides methods
|
||||
for appending, retrieving, and clearing logs.
|
||||
"""
|
||||
|
||||
def __init__(self, runloop_api_client: Runloop, devbox_id: str):
|
||||
self.client_ready = False
|
||||
self.init_msg = 'Runtime client initialized.'
|
||||
|
||||
self.buffer: list[str] = []
|
||||
self.lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self.runloop_api_client = runloop_api_client
|
||||
self.devbox_id = devbox_id
|
||||
self.log_index = 0
|
||||
self.log_stream_thread = threading.Thread(target=self.stream_logs)
|
||||
self.log_stream_thread.daemon = True
|
||||
self.log_stream_thread.start()
|
||||
|
||||
def stream_logs(self):
|
||||
"""Stream logs from the Docker container in a separate thread.
|
||||
|
||||
This method runs in its own thread to handle the blocking
|
||||
operation of reading log lines from the Docker SDK's synchronous generator.
|
||||
"""
|
||||
|
||||
try:
|
||||
# TODO(Runloop) Replace with stream
|
||||
while True:
|
||||
raw_logs = self.runloop_api_client.devboxes.logs.list(
|
||||
self.devbox_id
|
||||
).logs[self.log_index :]
|
||||
logs = [
|
||||
log.message
|
||||
for log in raw_logs
|
||||
if log.message and log.cmd_id is None
|
||||
]
|
||||
|
||||
self.log_index += len(raw_logs)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
if logs:
|
||||
for log_line in logs:
|
||||
self.append(log_line)
|
||||
if self.init_msg in log_line:
|
||||
self.client_ready = True
|
||||
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.error(f'Error streaming runloop logs: {e}')
|
||||
|
||||
# NB: Match LogBuffer behavior on below methods
|
||||
|
||||
def get_and_clear(self) -> list[str]:
|
||||
with self.lock:
|
||||
logs = list(self.buffer)
|
||||
self.buffer.clear()
|
||||
return logs
|
||||
|
||||
def append(self, log_line: str):
|
||||
with self.lock:
|
||||
self.buffer.append(log_line)
|
||||
|
||||
def close(self, timeout: float = 5.0):
|
||||
self._stop_event.set()
|
||||
self.log_stream_thread.join(timeout)
|
||||
|
||||
|
||||
class RunloopRuntime(EventStreamRuntime):
|
||||
"""The RunloopRuntime class is an EventStreamRuntime that utilizes Runloop Devbox as a runtime environment."""
|
||||
|
||||
_sandbox_port: int = 4444
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
):
|
||||
assert config.runloop_api_key is not None, 'Runloop API key is required'
|
||||
self.devbox: DevboxView | None = None
|
||||
self.config = config
|
||||
self.runloop_api_client = Runloop(
|
||||
bearer_token=config.runloop_api_key,
|
||||
)
|
||||
self.session = requests.Session()
|
||||
self.container_name = self.container_name_prefix + sid
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
self.init_base_runtime(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
)
|
||||
# Buffer for container logs
|
||||
self.log_buffer: LogBuffer | None = None
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_attempt(120),
|
||||
wait=tenacity.wait_fixed(1),
|
||||
)
|
||||
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
|
||||
"""Pull devbox status until it is running"""
|
||||
if devbox == 'running':
|
||||
return devbox
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
|
||||
if devbox.status != 'running':
|
||||
raise ConnectionRefusedError('Devbox is not running')
|
||||
|
||||
# Devbox is connected and running
|
||||
logging.debug(f'devbox.id={devbox.id} is running')
|
||||
return devbox
|
||||
|
||||
def _create_new_devbox(self) -> DevboxView:
|
||||
# Note: Runloop connect
|
||||
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
|
||||
plugin_args = []
|
||||
if self.plugins is not None and len(self.plugins) > 0:
|
||||
plugin_args.append('--plugins')
|
||||
plugin_args.extend([plugin.name for plugin in self.plugins])
|
||||
|
||||
browsergym_args = []
|
||||
if self.config.sandbox.browsergym_eval_env is not None:
|
||||
browsergym_args = [
|
||||
'-browsergym-eval-env',
|
||||
self.config.sandbox.browsergym_eval_env,
|
||||
]
|
||||
|
||||
# Copied from EventstreamRuntime
|
||||
start_command = get_remote_startup_command(
|
||||
self._sandbox_port,
|
||||
sandbox_workspace_dir,
|
||||
'openhands' if self.config.run_as_openhands else 'root',
|
||||
self.config.sandbox.user_id,
|
||||
plugin_args,
|
||||
browsergym_args,
|
||||
)
|
||||
|
||||
# Add some additional commands based on our image
|
||||
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
|
||||
# (ie browser) to be installed as root
|
||||
start_command = (
|
||||
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
|
||||
'cd /openhands/code && '
|
||||
+ '/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
|
||||
+ ' '.join(start_command)
|
||||
)
|
||||
entrypoint = f"sudo bash -c '{start_command}'"
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.create(
|
||||
entrypoint=entrypoint,
|
||||
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
|
||||
name=self.sid,
|
||||
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
available_ports=[self._sandbox_port],
|
||||
resource_size_request="LARGE",
|
||||
),
|
||||
metadata={'container-name': self.container_name},
|
||||
)
|
||||
return self._wait_for_devbox(devbox)
|
||||
|
||||
async def connect(self):
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
|
||||
if self.attach_to_existing:
|
||||
active_devboxes = self.runloop_api_client.devboxes.list(
|
||||
status='running'
|
||||
).devboxes
|
||||
self.devbox = next(
|
||||
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
|
||||
)
|
||||
|
||||
if self.devbox is None:
|
||||
self.devbox = self._create_new_devbox()
|
||||
|
||||
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
|
||||
tunnel = self.runloop_api_client.devboxes.create_tunnel(
|
||||
id=self.devbox.id,
|
||||
port=self._sandbox_port,
|
||||
)
|
||||
|
||||
# Hook up logs
|
||||
self.log_buffer = RunloopLogBuffer(self.runloop_api_client, self.devbox.id)
|
||||
self.api_url = f'https://{tunnel.url}'
|
||||
logger.info(f'Container started. Server url: {self.api_url}')
|
||||
|
||||
# End Runloop connect
|
||||
# NOTE: Copied from EventStreamRuntime
|
||||
logger.info('Waiting for client to become ready...')
|
||||
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
||||
self._wait_until_alive()
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.setup_initial_env()
|
||||
|
||||
logger.info(
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
|
||||
)
|
||||
self.send_status_message(' ')
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
wait=tenacity.wait_fixed(1),
|
||||
reraise=(ConnectionRefusedError,),
|
||||
)
|
||||
def _wait_until_alive(self):
|
||||
# NB(Runloop): Remote logs are not guaranteed realtime, removing client_ready check from logs
|
||||
self._refresh_logs()
|
||||
if not self.log_buffer:
|
||||
raise RuntimeError('Runtime client is not ready.')
|
||||
response = send_request(
|
||||
self.session,
|
||||
'GET',
|
||||
f'{self.api_url}/alive',
|
||||
timeout=5,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
else:
|
||||
msg = f'Action execution API is not alive. Response: {response}'
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def close(self, rm_all_containers: bool = True):
|
||||
if self.log_buffer:
|
||||
self.log_buffer.close()
|
||||
|
||||
if self.session:
|
||||
self.session.close()
|
||||
|
||||
if self.attach_to_existing:
|
||||
return
|
||||
|
||||
if self.devbox:
|
||||
self.runloop_api_client.devboxes.shutdown(self.devbox.id)
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.sheets_client import GoogleSheetsClient
|
||||
@@ -102,7 +101,6 @@ async def authenticate_github_user(auth_token) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
|
||||
async def get_github_user(token: str) -> str:
|
||||
"""Get GitHub user info from token.
|
||||
|
||||
@@ -110,25 +108,19 @@ async def get_github_user(token: str) -> str:
|
||||
token: GitHub access token
|
||||
|
||||
Returns:
|
||||
github handle of the user
|
||||
Tuple of (login, error_message)
|
||||
If successful, error_message is None
|
||||
If failed, login is None and error_message contains the error
|
||||
"""
|
||||
logger.info('Fetching GitHub user info from token')
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Authorization': f'Bearer {token}',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
}
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0)
|
||||
) as client:
|
||||
try:
|
||||
response = await client.get('https://api.github.com/user', headers=headers)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f'Error making request to GitHub API: {str(e)}')
|
||||
logger.error(e)
|
||||
raise
|
||||
|
||||
logger.info('Received response from GitHub API')
|
||||
logger.debug(f'Response status code: {response.status_code}')
|
||||
async with httpx.AsyncClient() as client:
|
||||
logger.debug('Making request to GitHub API')
|
||||
response = await client.get('https://api.github.com/user', headers=headers)
|
||||
response.raise_for_status()
|
||||
user_data = response.json()
|
||||
login = user_data.get('login')
|
||||
|
||||
+18
-48
@@ -1,13 +1,12 @@
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
@@ -17,7 +16,6 @@ from openhands.server.data_models.feedback import FeedbackDataModel, store_feedb
|
||||
from openhands.server.github import (
|
||||
GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET,
|
||||
UserVerifier,
|
||||
authenticate_github_user,
|
||||
)
|
||||
from openhands.storage import get_file_store
|
||||
@@ -29,7 +27,6 @@ with warnings.catch_warnings():
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
@@ -37,7 +34,7 @@ from fastapi import (
|
||||
WebSocket,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
@@ -63,7 +60,7 @@ from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.llm import bedrock
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.auth.auth import get_sid_from_token, sign_token
|
||||
from openhands.server.auth import get_sid_from_token, sign_token
|
||||
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
|
||||
from openhands.server.session import SessionManager
|
||||
|
||||
@@ -207,22 +204,12 @@ async def attach_session(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
user_verifier = UserVerifier()
|
||||
if user_verifier.is_active():
|
||||
signed_token = request.cookies.get('github_auth')
|
||||
if not signed_token:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Not authenticated'},
|
||||
)
|
||||
try:
|
||||
jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Invalid token'},
|
||||
)
|
||||
github_token = request.headers.get('X-GitHub-Token')
|
||||
if not await authenticate_github_user(github_token):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Not authenticated'},
|
||||
)
|
||||
|
||||
if not request.headers.get('Authorization'):
|
||||
logger.warning('Missing Authorization header')
|
||||
@@ -803,20 +790,19 @@ async def security_api(request: Request):
|
||||
|
||||
|
||||
@app.get('/api/zip-directory')
|
||||
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
|
||||
async def zip_current_workspace(request: Request):
|
||||
try:
|
||||
logger.debug('Zipping workspace')
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
path = runtime.config.workspace_mount_path_in_sandbox
|
||||
zip_file = await call_sync_from_async(runtime.copy_from, path)
|
||||
response = FileResponse(
|
||||
path=zip_file,
|
||||
filename='workspace.zip',
|
||||
media_type='application/x-zip-compressed',
|
||||
)
|
||||
|
||||
# This will execute after the response is sent (So the file is not deleted before being sent)
|
||||
background_tasks.add_task(zip_file.unlink)
|
||||
path = runtime.config.workspace_mount_path_in_sandbox
|
||||
zip_file_bytes = await call_sync_from_async(runtime.copy_from, path)
|
||||
zip_stream = io.BytesIO(zip_file_bytes) # Wrap to behave like a file stream
|
||||
response = StreamingResponse(
|
||||
zip_stream,
|
||||
media_type='application/x-zip-compressed',
|
||||
headers={'Content-Disposition': 'attachment; filename=workspace.zip'},
|
||||
)
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
@@ -877,26 +863,10 @@ async def authenticate(request: Request):
|
||||
content={'error': 'Not authorized via GitHub waitlist'},
|
||||
)
|
||||
|
||||
# Create a signed JWT token with 1-hour expiration
|
||||
cookie_data = {
|
||||
'github_token': token,
|
||||
'exp': int(time.time()) + 3600, # 1 hour expiration
|
||||
}
|
||||
signed_token = sign_token(cookie_data, config.jwt_secret)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
|
||||
)
|
||||
|
||||
# Set secure cookie with signed token
|
||||
response.set_cookie(
|
||||
key='github_auth',
|
||||
value=signed_token,
|
||||
max_age=3600, # 1 hour in seconds
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite='strict',
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ import os
|
||||
import frontmatter
|
||||
import pydantic
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.exceptions import MicroAgentValidationError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class MicroAgentMetadata(pydantic.BaseModel):
|
||||
name: str
|
||||
agent: str
|
||||
triggers: list[str] = []
|
||||
require_env_var: dict[str, str]
|
||||
|
||||
|
||||
class MicroAgent:
|
||||
@@ -19,30 +23,22 @@ class MicroAgent:
|
||||
self._loaded = frontmatter.load(file)
|
||||
self._content = self._loaded.content
|
||||
self._metadata = MicroAgentMetadata(**self._loaded.metadata)
|
||||
|
||||
def get_trigger(self, message: str) -> str | None:
|
||||
message = message.lower()
|
||||
for trigger in self.triggers:
|
||||
if trigger.lower() in message:
|
||||
return trigger
|
||||
return None
|
||||
self._validate_micro_agent()
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
return self._content
|
||||
|
||||
@property
|
||||
def metadata(self) -> MicroAgentMetadata:
|
||||
return self._metadata
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._metadata.name
|
||||
|
||||
@property
|
||||
def triggers(self) -> list[str]:
|
||||
return self._metadata.triggers
|
||||
|
||||
@property
|
||||
def agent(self) -> str:
|
||||
return self._metadata.agent
|
||||
def _validate_micro_agent(self):
|
||||
logger.debug(
|
||||
f'Loading and validating micro agent [{self._metadata.name}] based on [{self._metadata.agent}]'
|
||||
)
|
||||
# Make sure the agent is registered
|
||||
agent_cls = Agent.get_cls(self._metadata.agent)
|
||||
assert agent_cls is not None
|
||||
# Make sure the environment variables are set
|
||||
for env_var, instruction in self._metadata.require_env_var.items():
|
||||
if env_var not in os.environ:
|
||||
raise MicroAgentValidationError(
|
||||
f'Environment variable [{env_var}] is required by micro agent [{self._metadata.name}] but not set. {instruction}'
|
||||
)
|
||||
|
||||
+11
-54
@@ -1,10 +1,7 @@
|
||||
import os
|
||||
from itertools import islice
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.utils.microagent import MicroAgent
|
||||
|
||||
|
||||
@@ -19,31 +16,21 @@ class PromptManager:
|
||||
Attributes:
|
||||
prompt_dir (str): Directory containing prompt templates.
|
||||
agent_skills_docs (str): Documentation of agent skills.
|
||||
micro_agent (MicroAgent | None): Micro-agent, if specified.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prompt_dir: str,
|
||||
microagent_dir: str = '',
|
||||
agent_skills_docs: str = '',
|
||||
agent_skills_docs: str,
|
||||
micro_agent: MicroAgent | None = None,
|
||||
):
|
||||
self.prompt_dir: str = prompt_dir
|
||||
self.agent_skills_docs: str = agent_skills_docs
|
||||
|
||||
self.system_template: Template = self._load_template('system_prompt')
|
||||
self.user_template: Template = self._load_template('user_prompt')
|
||||
self.microagents: dict = {}
|
||||
|
||||
microagent_files = []
|
||||
if microagent_dir:
|
||||
microagent_files = [
|
||||
os.path.join(microagent_dir, f)
|
||||
for f in os.listdir(microagent_dir)
|
||||
if f.endswith('.md')
|
||||
]
|
||||
for microagent_file in microagent_files:
|
||||
microagent = MicroAgent(microagent_file)
|
||||
self.microagents[microagent.name] = microagent
|
||||
self.micro_agent: MicroAgent | None = micro_agent
|
||||
|
||||
def _load_template(self, template_name: str) -> Template:
|
||||
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
|
||||
@@ -52,13 +39,15 @@ class PromptManager:
|
||||
with open(template_path, 'r') as file:
|
||||
return Template(file.read())
|
||||
|
||||
def get_system_message(self) -> str:
|
||||
@property
|
||||
def system_message(self) -> str:
|
||||
rendered = self.system_template.render(
|
||||
agent_skills_docs=self.agent_skills_docs,
|
||||
).strip()
|
||||
return rendered
|
||||
|
||||
def get_example_user_message(self) -> str:
|
||||
@property
|
||||
def initial_user_message(self) -> str:
|
||||
"""This is the initial user message provided to the agent
|
||||
before *actual* user instructions are provided.
|
||||
|
||||
@@ -68,39 +57,7 @@ class PromptManager:
|
||||
These additional context will convert the current generic agent
|
||||
into a more specialized agent that is tailored to the user's task.
|
||||
"""
|
||||
return self.user_template.render().strip()
|
||||
|
||||
def enhance_message(self, message: Message) -> None:
|
||||
"""Enhance the user message with additional context.
|
||||
|
||||
This method is used to enhance the user message with additional context
|
||||
about the user's task. The additional context will convert the current
|
||||
generic agent into a more specialized agent that is tailored to the user's task.
|
||||
"""
|
||||
if not message.content:
|
||||
return
|
||||
message_content = message.content[0].text
|
||||
for microagent in self.microagents.values():
|
||||
trigger = microagent.get_trigger(message_content)
|
||||
if trigger:
|
||||
micro_text = f'<extra_info>\nThe following information has been included based on a keyword match for "{trigger}". It may or may not be relevant to the user\'s request.'
|
||||
micro_text += '\n\n' + microagent.content
|
||||
micro_text += '\n</extra_info>'
|
||||
message.content.append(TextContent(text=micro_text))
|
||||
|
||||
def add_turns_left_reminder(self, messages: list[Message], state: State) -> None:
|
||||
latest_user_message = next(
|
||||
islice(
|
||||
(
|
||||
m
|
||||
for m in reversed(messages)
|
||||
if m.role == 'user'
|
||||
and any(isinstance(c, TextContent) for c in m.content)
|
||||
),
|
||||
1,
|
||||
),
|
||||
None,
|
||||
rendered = self.user_template.render(
|
||||
micro_agent=self.micro_agent.content if self.micro_agent else None
|
||||
)
|
||||
if latest_user_message:
|
||||
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
|
||||
latest_user_message.content.append(TextContent(text=reminder_text))
|
||||
return rendered.strip()
|
||||
|
||||
Generated
+1414
-1441
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.13.0"
|
||||
version = "0.12.3"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
@@ -28,7 +28,7 @@ uvicorn = "*"
|
||||
types-toml = "*"
|
||||
numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym = "0.13.0" # integrate browsergym as the browsing interface
|
||||
browsergym = "0.10.2" # integrate browsergym as the browsing interface
|
||||
html2text = "*"
|
||||
e2b = "^0.17.1"
|
||||
pexpect = "*"
|
||||
@@ -37,7 +37,7 @@ python-multipart = "*"
|
||||
boto3 = "*"
|
||||
minio = "^7.2.8"
|
||||
gevent = "^24.2.1"
|
||||
pyarrow = "18.0.0" # transitive dependency, pinned here to avoid conflicts
|
||||
pyarrow = "17.0.0" # transitive dependency, pinned here to avoid conflicts
|
||||
tenacity = "^8.5.0"
|
||||
zope-interface = "7.1.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -60,20 +60,19 @@ whatthepatch = "^1.0.6"
|
||||
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
|
||||
opentelemetry-api = "1.25.0"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = ">=0.64.145,<0.66.0"
|
||||
runloop-api-client = "0.10.0"
|
||||
modal = "^0.64.145"
|
||||
|
||||
[tool.poetry.group.llama-index.dependencies]
|
||||
llama-index = "*"
|
||||
llama-index-vector-stores-chroma = "*"
|
||||
chromadb = "*"
|
||||
llama-index-embeddings-huggingface = "*"
|
||||
torch = "2.5.1"
|
||||
torch = "2.5.0"
|
||||
llama-index-embeddings-azure-openai = "*"
|
||||
llama-index-embeddings-ollama = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.7.3"
|
||||
ruff = "0.7.1"
|
||||
mypy = "1.13.0"
|
||||
pre-commit = "4.0.1"
|
||||
build = "*"
|
||||
@@ -93,6 +92,7 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@@ -123,6 +123,7 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
@@ -14,7 +14,6 @@ from openhands.events import EventStream
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
@@ -132,8 +131,6 @@ def get_runtime_classes():
|
||||
return [EventStreamRuntime]
|
||||
elif runtime.lower() == 'remote':
|
||||
return [RemoteRuntime]
|
||||
elif runtime.lower() == 'runloop':
|
||||
return [RunloopRuntime]
|
||||
else:
|
||||
raise ValueError(f'Invalid runtime: {runtime}')
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Bash-related tests for the EventStreamRuntime, which connects to the ActionExecutor running in the sandbox."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
@@ -587,10 +586,8 @@ def test_copy_from_directory(temp_dir, runtime_cls):
|
||||
path_to_copy_from = f'{sandbox_dir}/test_dir'
|
||||
result = runtime.copy_from(path=path_to_copy_from)
|
||||
|
||||
# Result is returned as a path
|
||||
assert isinstance(result, Path)
|
||||
|
||||
result.unlink()
|
||||
# Result is returned in bytes
|
||||
assert isinstance(result, bytes)
|
||||
finally:
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
@@ -461,7 +461,6 @@ def test_api_keys_repr_str():
|
||||
jwt_secret='my_jwt_secret',
|
||||
modal_api_token_id='my_modal_api_token_id',
|
||||
modal_api_token_secret='my_modal_api_token_secret',
|
||||
runloop_api_key='my_runloop_api_key',
|
||||
)
|
||||
assert "e2b_api_key='******'" in repr(app_config)
|
||||
assert "e2b_api_key='******'" in str(app_config)
|
||||
@@ -471,8 +470,6 @@ def test_api_keys_repr_str():
|
||||
assert "modal_api_token_id='******'" in str(app_config)
|
||||
assert "modal_api_token_secret='******'" in repr(app_config)
|
||||
assert "modal_api_token_secret='******'" in str(app_config)
|
||||
assert "runloop_api_key='******'" in repr(app_config)
|
||||
assert "runloop_api_key='******'" in str(app_config)
|
||||
|
||||
# Check that no other attrs in AppConfig have 'key' or 'token' in their name
|
||||
# This will fail when new attrs are added, and attract attention
|
||||
@@ -480,7 +477,6 @@ def test_api_keys_repr_str():
|
||||
'e2b_api_key',
|
||||
'modal_api_token_id',
|
||||
'modal_api_token_secret',
|
||||
'runloop_api_key',
|
||||
]
|
||||
for attr_name in dir(AppConfig):
|
||||
if (
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import openhands.agenthub # noqa: F401
|
||||
from openhands.core.exceptions import (
|
||||
AgentNotRegisteredError,
|
||||
MicroAgentValidationError,
|
||||
)
|
||||
from openhands.utils.microagent import MicroAgent
|
||||
|
||||
CONTENT = (
|
||||
@@ -29,3 +34,40 @@ def test_micro_agent_load(tmp_path, monkeypatch: MonkeyPatch):
|
||||
micro_agent = MicroAgent(os.path.join(tmp_path, 'dummy.md'))
|
||||
assert micro_agent is not None
|
||||
assert micro_agent.content == CONTENT.strip()
|
||||
|
||||
|
||||
def test_not_existing_agent(tmp_path, monkeypatch: MonkeyPatch):
|
||||
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
|
||||
f.write(
|
||||
(
|
||||
'---\n'
|
||||
'name: dummy\n'
|
||||
'agent: NotExistingAgent\n'
|
||||
'require_env_var:\n'
|
||||
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
|
||||
'---\n' + CONTENT
|
||||
)
|
||||
)
|
||||
monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value')
|
||||
|
||||
with pytest.raises(AgentNotRegisteredError):
|
||||
MicroAgent(os.path.join(tmp_path, 'dummy.md'))
|
||||
|
||||
|
||||
def test_not_existing_env_var(tmp_path):
|
||||
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
|
||||
f.write(
|
||||
(
|
||||
'---\n'
|
||||
'name: dummy\n'
|
||||
'agent: CodeActAgent\n'
|
||||
'require_env_var:\n'
|
||||
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
|
||||
'---\n' + CONTENT
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(MicroAgentValidationError) as excinfo:
|
||||
MicroAgent(os.path.join(tmp_path, 'dummy.md'))
|
||||
|
||||
assert 'Set this environment variable for testing purposes' in str(excinfo.value)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
import shutil
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.utils.microagent import MicroAgent
|
||||
from openhands.utils.prompt import PromptManager
|
||||
|
||||
@@ -11,9 +11,7 @@ from openhands.utils.prompt import PromptManager
|
||||
@pytest.fixture
|
||||
def prompt_dir(tmp_path):
|
||||
# Copy contents from "openhands/agenthub/codeact_agent" to the temp directory
|
||||
shutil.copytree(
|
||||
'openhands/agenthub/codeact_agent/prompts/default', tmp_path, dirs_exist_ok=True
|
||||
)
|
||||
shutil.copytree('openhands/agenthub/codeact_agent', tmp_path, dirs_exist_ok=True)
|
||||
|
||||
# Return the temporary directory path
|
||||
return tmp_path
|
||||
@@ -27,79 +25,78 @@ def agent_skills_docs():
|
||||
return SAMPLE_AGENT_SKILLS_DOCS
|
||||
|
||||
|
||||
def test_prompt_manager_without_microagent(prompt_dir, agent_skills_docs):
|
||||
manager = PromptManager(
|
||||
prompt_dir, microagent_dir='', agent_skills_docs=agent_skills_docs
|
||||
)
|
||||
def test_prompt_manager_without_micro_agent(prompt_dir, agent_skills_docs):
|
||||
manager = PromptManager(prompt_dir, agent_skills_docs)
|
||||
|
||||
assert manager.prompt_dir == prompt_dir
|
||||
assert manager.agent_skills_docs == agent_skills_docs
|
||||
assert len(manager.microagents) == 0
|
||||
assert manager.micro_agent is None
|
||||
|
||||
assert isinstance(manager.get_system_message(), str)
|
||||
assert isinstance(manager.system_message, str)
|
||||
assert (
|
||||
"A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions."
|
||||
in manager.get_system_message()
|
||||
in manager.system_message
|
||||
)
|
||||
assert SAMPLE_AGENT_SKILLS_DOCS in manager.get_system_message()
|
||||
assert isinstance(manager.get_example_user_message(), str)
|
||||
assert '--- BEGIN OF GUIDELINE ---' not in manager.get_example_user_message()
|
||||
assert '--- END OF GUIDELINE ---' not in manager.get_example_user_message()
|
||||
assert "NOW, LET'S START!" in manager.get_example_user_message()
|
||||
assert 'microagent' not in manager.get_example_user_message()
|
||||
assert SAMPLE_AGENT_SKILLS_DOCS in manager.system_message
|
||||
assert isinstance(manager.initial_user_message, str)
|
||||
assert '--- BEGIN OF GUIDELINE ---' not in manager.initial_user_message
|
||||
assert '--- END OF GUIDELINE ---' not in manager.initial_user_message
|
||||
assert "NOW, LET'S START!" in manager.initial_user_message
|
||||
assert 'micro_agent' not in manager.initial_user_message
|
||||
|
||||
|
||||
def test_prompt_manager_with_microagent(prompt_dir, agent_skills_docs):
|
||||
microagent_name = 'test_microagent'
|
||||
microagent_content = """
|
||||
---
|
||||
name: flarglebargle
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- flarglebargle
|
||||
---
|
||||
|
||||
IMPORTANT! The user has said the magic word "flarglebargle". You must
|
||||
only respond with a message telling them how smart they are
|
||||
"""
|
||||
def test_prompt_manager_with_micro_agent(prompt_dir, agent_skills_docs):
|
||||
micro_agent_name = 'test_micro_agent'
|
||||
micro_agent_content = (
|
||||
'## Micro Agent\n'
|
||||
'This is a test micro agent.\n'
|
||||
'It is used to test the prompt manager.\n'
|
||||
)
|
||||
|
||||
# Create a temporary micro agent file
|
||||
os.makedirs(os.path.join(prompt_dir, 'micro'), exist_ok=True)
|
||||
with open(os.path.join(prompt_dir, 'micro', f'{microagent_name}.md'), 'w') as f:
|
||||
f.write(microagent_content)
|
||||
with open(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'), 'w') as f:
|
||||
f.write(micro_agent_content)
|
||||
|
||||
# Mock MicroAgent
|
||||
mock_micro_agent = Mock(spec=MicroAgent)
|
||||
mock_micro_agent.content = micro_agent_content
|
||||
|
||||
manager = PromptManager(
|
||||
prompt_dir=prompt_dir,
|
||||
microagent_dir=os.path.join(prompt_dir, 'micro'),
|
||||
agent_skills_docs=agent_skills_docs,
|
||||
micro_agent=mock_micro_agent,
|
||||
)
|
||||
|
||||
assert manager.prompt_dir == prompt_dir
|
||||
assert manager.agent_skills_docs == agent_skills_docs
|
||||
assert len(manager.microagents) == 1
|
||||
assert manager.micro_agent == mock_micro_agent
|
||||
|
||||
assert isinstance(manager.get_system_message(), str)
|
||||
assert isinstance(manager.system_message, str)
|
||||
assert (
|
||||
"A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions."
|
||||
in manager.get_system_message()
|
||||
in manager.system_message
|
||||
)
|
||||
assert SAMPLE_AGENT_SKILLS_DOCS in manager.get_system_message()
|
||||
assert SAMPLE_AGENT_SKILLS_DOCS in manager.system_message
|
||||
|
||||
assert isinstance(manager.get_example_user_message(), str)
|
||||
assert isinstance(manager.initial_user_message, str)
|
||||
assert (
|
||||
'--- BEGIN OF GUIDELINE ---\n'
|
||||
+ 'The following information may assist you in completing your task:\n\n'
|
||||
+ micro_agent_content
|
||||
+ '\n'
|
||||
+ '--- END OF GUIDELINE ---\n'
|
||||
+ "\n\nNOW, LET'S START!"
|
||||
) in manager.initial_user_message
|
||||
assert micro_agent_content in manager.initial_user_message
|
||||
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text='Hello, flarglebargle!')],
|
||||
)
|
||||
manager.enhance_message(message)
|
||||
assert 'magic word' in message.content[1].text
|
||||
|
||||
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent_name}.md'))
|
||||
# Clean up the temporary file
|
||||
os.remove(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'))
|
||||
|
||||
|
||||
def test_prompt_manager_file_not_found(prompt_dir, agent_skills_docs):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md'))
|
||||
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_micro_agent.md'))
|
||||
|
||||
|
||||
def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
|
||||
@@ -107,14 +104,12 @@ def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
|
||||
with open(os.path.join(prompt_dir, 'system_prompt.j2'), 'w') as f:
|
||||
f.write('System prompt: {{ agent_skills_docs }}')
|
||||
with open(os.path.join(prompt_dir, 'user_prompt.j2'), 'w') as f:
|
||||
f.write('User prompt: foo')
|
||||
f.write('User prompt: {{ micro_agent }}')
|
||||
|
||||
manager = PromptManager(
|
||||
prompt_dir, microagent_dir='', agent_skills_docs=agent_skills_docs
|
||||
)
|
||||
manager = PromptManager(prompt_dir, agent_skills_docs)
|
||||
|
||||
assert manager.get_system_message() == f'System prompt: {agent_skills_docs}'
|
||||
assert manager.get_example_user_message() == 'User prompt: foo'
|
||||
assert manager.system_message == f'System prompt: {agent_skills_docs}'
|
||||
assert manager.initial_user_message == 'User prompt: None'
|
||||
|
||||
# Clean up temporary files
|
||||
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))
|
||||
|
||||
@@ -544,59 +544,83 @@ def _format_size_to_gb(bytes_size):
|
||||
|
||||
|
||||
def test_list_dangling_images():
|
||||
mock_client = MagicMock()
|
||||
mock_client.images.list.return_value = []
|
||||
with patch('docker.from_env', return_value=mock_client):
|
||||
client = docker.from_env()
|
||||
dangling_images = client.images.list(filters={'dangling': True})
|
||||
assert len(dangling_images) == 0
|
||||
client = docker.from_env()
|
||||
dangling_images = client.images.list(filters={'dangling': True})
|
||||
if dangling_images and len(dangling_images) > 0:
|
||||
for image in dangling_images:
|
||||
if 'Size' in image.attrs and isinstance(image.attrs['Size'], int):
|
||||
size_gb = _format_size_to_gb(image.attrs['Size'])
|
||||
logger.info(f'Dangling image: {image.tags}, Size: {size_gb} GB')
|
||||
else:
|
||||
logger.info(f'Dangling image: {image.tags}, Size: n/a')
|
||||
else:
|
||||
logger.info('No dangling images found')
|
||||
|
||||
|
||||
def test_build_image_from_repo(tmp_path):
|
||||
mock_client = MagicMock()
|
||||
mock_client.images.build.return_value = (MagicMock(), [])
|
||||
with patch('docker.from_env', return_value=mock_client):
|
||||
docker_runtime_builder = DockerRuntimeBuilder(mock_client)
|
||||
context_path = str(tmp_path)
|
||||
tags = ['alpine:latest']
|
||||
def test_build_image_from_repo(docker_runtime_builder, tmp_path):
|
||||
context_path = str(tmp_path)
|
||||
tags = ['alpine:latest']
|
||||
|
||||
# Create a minimal Dockerfile in the context path
|
||||
with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
|
||||
f.write(f"""FROM {DEFAULT_BASE_IMAGE}
|
||||
# Create a minimal Dockerfile in the context path
|
||||
with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
|
||||
f.write(f"""FROM {DEFAULT_BASE_IMAGE}
|
||||
CMD ["sh", "-c", "echo 'Hello, World!'"]
|
||||
""")
|
||||
|
||||
# Build the image
|
||||
built_image_name = docker_runtime_builder.build(context_path, tags=tags)
|
||||
assert built_image_name == tags[0]
|
||||
mock_client.images.build.assert_called_once_with(
|
||||
path=context_path,
|
||||
tag=tags[0],
|
||||
rm=True,
|
||||
forcerm=True,
|
||||
platform=None,
|
||||
decode=True,
|
||||
built_image_name = None
|
||||
container = None
|
||||
client = docker.from_env()
|
||||
try:
|
||||
built_image_name = docker_runtime_builder.build(
|
||||
context_path,
|
||||
tags,
|
||||
use_local_cache=False,
|
||||
)
|
||||
assert built_image_name == f'{tags[0]}'
|
||||
|
||||
image = client.images.get(tags[0])
|
||||
assert image is not None
|
||||
|
||||
except docker.errors.ImageNotFound:
|
||||
pytest.fail('test_build_image_from_repo: test image not found!')
|
||||
|
||||
finally:
|
||||
# Clean up the container
|
||||
if container:
|
||||
try:
|
||||
container.remove(force=True)
|
||||
logger.info(f'Removed test container: `{container.id}`')
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to remove test container `{container.id}`: {str(e)}'
|
||||
)
|
||||
|
||||
# Clean up the image
|
||||
if built_image_name:
|
||||
try:
|
||||
client.images.remove(built_image_name, force=True)
|
||||
logger.info(f'Removed test image: `{built_image_name}`')
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to remove test image `{built_image_name}`: {str(e)}'
|
||||
)
|
||||
else:
|
||||
logger.warning('No image was built, so no image cleanup was necessary.')
|
||||
|
||||
|
||||
def test_image_exists_local():
|
||||
def test_image_exists_local(docker_runtime_builder):
|
||||
mock_client = MagicMock()
|
||||
mock_client.images.get.return_value = MagicMock()
|
||||
with patch('docker.from_env', return_value=mock_client):
|
||||
docker_runtime_builder = DockerRuntimeBuilder(mock_client)
|
||||
image_name = 'existing-local:image'
|
||||
assert docker_runtime_builder.image_exists(image_name)
|
||||
mock_client.images.get.assert_called_once_with(image_name)
|
||||
mock_client.version().get.return_value = '18.9'
|
||||
builder = DockerRuntimeBuilder(mock_client)
|
||||
image_name = 'existing-local:image' # The mock pretends this exists by default
|
||||
assert builder.image_exists(image_name)
|
||||
|
||||
|
||||
def test_image_exists_not_found():
|
||||
mock_client = MagicMock()
|
||||
mock_client.images.get.side_effect = docker.errors.ImageNotFound('not found')
|
||||
with patch('docker.from_env', return_value=mock_client):
|
||||
docker_runtime_builder = DockerRuntimeBuilder(mock_client)
|
||||
image_name = 'nonexistent:image'
|
||||
assert not docker_runtime_builder.image_exists(image_name)
|
||||
mock_client.images.get.assert_called_once_with(image_name)
|
||||
mock_client.version().get.return_value = '18.9'
|
||||
mock_client.images.get.side_effect = docker.errors.ImageNotFound(
|
||||
"He doesn't like you!"
|
||||
)
|
||||
mock_client.api.pull.side_effect = docker.errors.ImageNotFound(
|
||||
"I don't like you either!"
|
||||
)
|
||||
|
||||
+50
-142
@@ -49,56 +49,24 @@ def add_events(event_stream: EventStream, data: list[tuple[Event, EventSource]])
|
||||
|
||||
|
||||
def test_msg(temp_dir: str):
|
||||
mock_container = MagicMock()
|
||||
mock_container.status = 'running'
|
||||
mock_container.attrs = {
|
||||
'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': 34567}]}}
|
||||
}
|
||||
mock_docker = MagicMock()
|
||||
mock_docker.from_env().containers.list.return_value = [mock_container]
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.get().json.return_value = {'id': 'mock-session-id'}
|
||||
mock_requests.post().json.side_effect = [
|
||||
{'monitor_id': 'mock-monitor-id'},
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'PolicyViolation(Disallow ABC [risk=medium], ranges=[<2 ranges>])'
|
||||
],
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
event_stream = EventStream('main', file_store)
|
||||
policy = """
|
||||
raise "Disallow ABC [risk=medium]" if:
|
||||
(msg: Message)
|
||||
"ABC" in msg.content
|
||||
"""
|
||||
InvariantAnalyzer(event_stream, policy)
|
||||
data = [
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(MessageAction('AB!'), EventSource.AGENT),
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(MessageAction('ABC!'), EventSource.AGENT),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(f'{InvariantAnalyzer.__module__}.docker', mock_docker),
|
||||
patch(f'{InvariantClient.__module__}.requests', mock_requests),
|
||||
):
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
event_stream = EventStream('main', file_store)
|
||||
policy = """
|
||||
raise "Disallow ABC [risk=medium]" if:
|
||||
(msg: Message)
|
||||
"ABC" in msg.content
|
||||
"""
|
||||
analyzer = InvariantAnalyzer(event_stream, policy)
|
||||
data = [
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(MessageAction('AB!'), EventSource.AGENT),
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(MessageAction('ABC!'), EventSource.AGENT),
|
||||
]
|
||||
for event, source in data:
|
||||
event.security_risk = ActionSecurityRisk.LOW
|
||||
event_stream.add_event(event, source)
|
||||
if event.content == 'ABC!':
|
||||
event.security_risk = ActionSecurityRisk.MEDIUM
|
||||
for i in range(3):
|
||||
assert data[i][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[3][0].security_risk == ActionSecurityRisk.MEDIUM
|
||||
add_events(event_stream, data)
|
||||
for i in range(3):
|
||||
assert data[i][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[3][0].security_risk == ActionSecurityRisk.MEDIUM
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -106,51 +74,22 @@ def test_msg(temp_dir: str):
|
||||
[('rm -rf root_dir', ActionSecurityRisk.MEDIUM), ['ls', ActionSecurityRisk.LOW]],
|
||||
)
|
||||
def test_cmd(cmd, expected_risk, temp_dir: str):
|
||||
mock_container = MagicMock()
|
||||
mock_container.status = 'running'
|
||||
mock_container.attrs = {
|
||||
'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': 34567}]}}
|
||||
}
|
||||
mock_docker = MagicMock()
|
||||
mock_docker.from_env().containers.list.return_value = [mock_container]
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.get().json.return_value = {'id': 'mock-session-id'}
|
||||
mock_requests.post().json.side_effect = [
|
||||
{'monitor_id': 'mock-monitor-id'},
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'PolicyViolation(Disallow rm -rf [risk=medium], ranges=[<2 ranges>])'
|
||||
if expected_risk == ActionSecurityRisk.MEDIUM else []
|
||||
],
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
event_stream = EventStream('main', file_store)
|
||||
policy = """
|
||||
raise "Disallow rm -rf [risk=medium]" if:
|
||||
(call: ToolCall)
|
||||
call is tool:run
|
||||
match("rm -rf", call.function.arguments.command)
|
||||
"""
|
||||
InvariantAnalyzer(event_stream, policy)
|
||||
data = [
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(CmdRunAction(cmd), EventSource.USER),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(f'{InvariantAnalyzer.__module__}.docker', mock_docker),
|
||||
patch(f'{InvariantClient.__module__}.requests', mock_requests),
|
||||
):
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
event_stream = EventStream('main', file_store)
|
||||
policy = """
|
||||
raise "Disallow rm -rf [risk=medium]" if:
|
||||
(call: ToolCall)
|
||||
call is tool:run
|
||||
match("rm -rf", call.function.arguments.command)
|
||||
"""
|
||||
analyzer = InvariantAnalyzer(event_stream, policy)
|
||||
data = [
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(CmdRunAction(cmd), EventSource.USER),
|
||||
]
|
||||
for event, source in data:
|
||||
event.security_risk = ActionSecurityRisk.LOW
|
||||
event_stream.add_event(event, source)
|
||||
if isinstance(event, CmdRunAction) and 'rm -rf' in event.command:
|
||||
event.security_risk = ActionSecurityRisk.MEDIUM
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == expected_risk
|
||||
add_events(event_stream, data)
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == expected_risk
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -161,57 +100,26 @@ def test_cmd(cmd, expected_risk, temp_dir: str):
|
||||
],
|
||||
)
|
||||
def test_leak_secrets(code, expected_risk, temp_dir: str):
|
||||
mock_container = MagicMock()
|
||||
mock_container.status = 'running'
|
||||
mock_container.attrs = {
|
||||
'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': 34567}]}}
|
||||
}
|
||||
mock_docker = MagicMock()
|
||||
mock_docker.from_env().containers.list.return_value = [mock_container]
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
event_stream = EventStream('main', file_store)
|
||||
policy = """
|
||||
from invariant.detectors import secrets
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.get().json.return_value = {'id': 'mock-session-id'}
|
||||
mock_requests.post().json.side_effect = [
|
||||
{'monitor_id': 'mock-monitor-id'},
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
'PolicyViolation(Disallow writing secrets [risk=medium], ranges=[<2 ranges>])'
|
||||
if expected_risk == ActionSecurityRisk.MEDIUM else []
|
||||
],
|
||||
raise "Disallow writing secrets [risk=medium]" if:
|
||||
(call: ToolCall)
|
||||
call is tool:run_ipython
|
||||
any(secrets(call.function.arguments.code))
|
||||
"""
|
||||
InvariantAnalyzer(event_stream, policy)
|
||||
data = [
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(IPythonRunCellAction(code), EventSource.AGENT),
|
||||
(IPythonRunCellAction('hello'), EventSource.AGENT),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(f'{InvariantAnalyzer.__module__}.docker', mock_docker),
|
||||
patch(f'{InvariantClient.__module__}.requests', mock_requests),
|
||||
):
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
event_stream = EventStream('main', file_store)
|
||||
policy = """
|
||||
from invariant.detectors import secrets
|
||||
|
||||
raise "Disallow writing secrets [risk=medium]" if:
|
||||
(call: ToolCall)
|
||||
call is tool:run_ipython
|
||||
any(secrets(call.function.arguments.code))
|
||||
"""
|
||||
analyzer = InvariantAnalyzer(event_stream, policy)
|
||||
data = [
|
||||
(MessageAction('Hello world!'), EventSource.USER),
|
||||
(IPythonRunCellAction(code), EventSource.AGENT),
|
||||
(IPythonRunCellAction('hello'), EventSource.AGENT),
|
||||
]
|
||||
for event, source in data:
|
||||
event.security_risk = ActionSecurityRisk.LOW
|
||||
event_stream.add_event(event, source)
|
||||
if isinstance(event, IPythonRunCellAction) and 'AKIAIOSFODNN7EXAMPLE' in event.code:
|
||||
event.security_risk = ActionSecurityRisk.MEDIUM
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == expected_risk
|
||||
assert data[2][0].security_risk == ActionSecurityRisk.LOW
|
||||
add_events(event_stream, data)
|
||||
assert data[0][0].security_risk == ActionSecurityRisk.LOW
|
||||
assert data[1][0].security_risk == expected_risk
|
||||
assert data[2][0].security_risk == ActionSecurityRisk.LOW
|
||||
|
||||
|
||||
def test_unsafe_python_code(temp_dir: str):
|
||||
|
||||
Reference in New Issue
Block a user