Compare commits

..

18 Commits

Author SHA1 Message Date
Robert Brennan 65a9d03da5 fix infinite loop (#4873)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-11-11 17:26:02 -05:00
Robert Brennan 9ba26daa76 move to github lib 2024-11-11 17:25:57 -05:00
Robert Brennan 091e7eb3c2 add github lib 2024-11-11 17:25:49 -05:00
Robert Brennan 35c68863dc Don't persist cache on reload (#4854) 2024-11-08 22:31:24 +00:00
mamoodi 8bfee87bcf Release 0.13.0 (#4849) 2024-11-08 22:24:56 +00:00
Robert Brennan e1383afbc3 Add signed cookie-based GitHub authentication caching (#4853)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-11-08 22:19:34 +00:00
Xingyao Wang 4ce3b9094a Revert "(feat): Prompt engineering to remind o1 to generate a patch" (#4846) 2024-11-08 16:12:57 +00:00
Graham Neubig 0a4e196670 Update openhands-resolver.yml to remove issue number (#4843) 2024-11-08 15:13:56 +00:00
Daniel Cruz 8d32a59f55 Adds missing localization and translation to spanish (#4837)
Co-authored-by: adrianamorenogt <adrianamorenogutierrez@gmail.com>
2024-11-08 09:33:19 +02:00
tofarr 38b92f4251 UX: Show a loading indicator when downloading a zip (#4833) 2024-11-08 09:28:18 +02:00
Boxuan Li 88dbe85594 Make trajectories_path support file path (#4840) 2024-11-08 06:26:12 +00:00
OpenHands f5003a7449 Fix issue #4830: [Bug]: Copy-paste into the "What do you want to build?" bar doesn't work (#4832)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-11-07 23:20:43 -06:00
Alejandro Cuadron Lafuente a6810fa6ad (feat): Prompt engineering to remind o1 to generate a patch (#4807)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Robert Brennan <contact@rbren.io>
2024-11-08 03:10:18 +00:00
Robert Brennan fc05d8d4eb instruct the agent to comment less (#4681) 2024-11-08 05:21:48 +08:00
sp.wack 1d6ef0e18e fix(frontend): Remove runtime indicator (#4829) 2024-11-08 02:37:59 +08:00
Xingyao Wang dc0e223d1a fix(agent controller): misplaced runtime.connect that cause swebench workspace to fail (#4826) 2024-11-08 01:50:33 +08:00
tofarr 932de79154 Fix: Buffering zip downloads to files rather than holding in memory (#4802) 2024-11-07 10:24:30 -07:00
Robert Brennan fa625fed70 Retry on github auth failure (#4767) 2024-11-07 16:57:06 +00:00
33 changed files with 1878 additions and 1769 deletions
+1 -1
View File
@@ -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:
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }}
max_iterations: 50
secrets: inherit
+1 -1
View File
@@ -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.12-nikolaik
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
## Develop inside Docker container
+3 -3
View File
@@ -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.12-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker run -it --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-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.12
docker.all-hands.dev/all-hands-ai/openhands:0.13
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -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.12-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -1
View File
@@ -32,7 +32,8 @@ workspace_base = "./workspace"
# Enable saving and restoring the session when run from CLI
#enable_cli_session = false
# Path to store trajectories
# 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
#trajectories_path="./trajectories"
# File store path
+1 -1
View File
@@ -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.12-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -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.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-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.12 \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
python -m openhands.core.cli
```
+2 -2
View File
@@ -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.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-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.12 \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+3 -3
View File
@@ -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.12-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-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.12
docker.all-hands.dev/all-hands-ai/openhands:0.13
```
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).
@@ -1,5 +1,5 @@
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/chat-input";
@@ -158,4 +158,46 @@ 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]);
});
});
+6 -27
View File
@@ -8,7 +8,6 @@ describe("Cache", () => {
const testTTL = 1000; // 1 second
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
});
@@ -16,17 +15,7 @@ describe("Cache", () => {
vi.useRealTimers();
});
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", () => {
it("gets data from memory if not expired", () => {
cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData);
@@ -39,7 +28,6 @@ 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", () => {
@@ -47,28 +35,19 @@ describe("Cache", () => {
vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("deletes data from localStorage", () => {
it("deletes data from memory", () => {
cache.set(testKey, testData, testTTL);
cache.delete(testKey);
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
expect(cache.get(testKey)).toBeNull();
});
it("clears all data with the app prefix from localStorage", () => {
it("clears all data with the app prefix from memory", () => {
cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll();
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");
expect(cache.get(testKey)).toBeNull();
expect(cache.get("anotherKey")).toBeNull();
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.12.3",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.12.3",
"version": "0.13.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.12.3",
"version": "0.13.0",
"private": true,
"type": "module",
"engines": {
@@ -120,4 +120,4 @@
"public"
]
}
}
}
+7 -2
View File
@@ -40,13 +40,18 @@ export function ChatInput({
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
event.preventDefault();
// Only handle paste if we have an image paste handler and there are files
if (onImagePaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files).filter((file) =>
file.type.startsWith("image/"),
);
if (files.length > 0) onImagePaste(files);
// Only prevent default if we found image files to handle
if (files.length > 0) {
event.preventDefault();
onImagePaste(files);
}
}
// For text paste, let the default behavior handle it
};
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
@@ -1,4 +1,4 @@
import { useFetcher } from "@remix-run/react";
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { BaseModalTitle } from "./confirmation-modals/BaseModal";
@@ -6,6 +6,7 @@ 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";
@@ -24,8 +25,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",
});
@@ -35,7 +36,7 @@ function AccountSettingsModal({
event.preventDefault();
const formData = new FormData(event.currentTarget);
const language = formData.get("language")?.toString();
const newGHToken = formData.get("ghToken")?.toString();
const ghToken = formData.get("ghToken")?.toString();
const analytics = formData.get("analytics")?.toString() === "on";
const accountForm = new FormData();
@@ -48,7 +49,7 @@ function AccountSettingsModal({
)?.value;
accountForm.append("language", languageKey ?? "en");
}
if (newGHToken) loginForm.append("ghToken", newGHToken);
if (ghToken) loginForm.append("ghToken", ghToken);
accountForm.append("analytics", analytics.toString());
settingsFetcher.submit(accountForm, {
@@ -84,14 +85,14 @@ function AccountSettingsModal({
name="ghToken"
label="GitHub Token"
type="password"
defaultValue={ghToken ?? ""}
defaultValue={data?.ghToken ?? ""}
/>
{gitHubError && (
<p className="text-danger text-xs">
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
</p>
)}
{ghToken && !gitHubError && (
{data?.ghToken && !gitHubError && (
<ModalButton
variant="text-like"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
@@ -1,4 +1,4 @@
import { useFetcher } from "@remix-run/react";
import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import ModalBody from "./ModalBody";
import { CustomInput } from "../form/custom-input";
@@ -7,6 +7,7 @@ import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/BaseModal";
import { clientLoader } from "#/routes/_oh";
import { clientAction } from "#/routes/login";
import { I18nKey } from "#/i18n/declaration";
@@ -15,7 +16,7 @@ interface ConnectToGitHubModalProps {
}
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
const ghToken = localStorage.getItem("ghToken");
const data = useRouteLoaderData<typeof clientLoader>("routes/_oh");
const fetcher = useFetcher<typeof clientAction>({ key: "login" });
const { t } = useTranslation();
@@ -50,7 +51,7 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
name="ghToken"
required
type="password"
defaultValue={ghToken ?? ""}
defaultValue={data?.ghToken ?? ""}
/>
<div className="flex flex-col gap-2 w-full">
@@ -12,6 +12,7 @@ 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;
@@ -32,6 +33,7 @@ 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);
@@ -63,7 +65,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
try {
downloadWorkspace();
setWorking(true);
downloadWorkspace().then(
() => setWorking(false),
() => setWorking(false),
);
} catch (error) {
toast.error("Failed to download workspace");
}
@@ -71,7 +77,7 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{contextMenuIsOpen && (
{!working && contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
@@ -98,7 +104,11 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
<EllipsisH width={36} height={36} />
{working ? (
<LoadingSpinner size="small" />
) : (
<EllipsisH width={36} height={36} />
)}
</button>
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
@@ -1,6 +1,8 @@
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;
@@ -18,7 +20,7 @@ export function ProjectMenuCardContextMenu({
onClose,
}: ProjectMenuCardContextMenuProps) {
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
return (
<ContextMenu
ref={menuRef}
@@ -26,16 +28,16 @@ export function ProjectMenuCardContextMenu({
>
{!isConnectedToGitHub && (
<ContextMenuListItem onClick={onConnectToGitHub}>
Connect to GitHub
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
</ContextMenuListItem>
)}
{isConnectedToGitHub && (
<ContextMenuListItem onClick={onPushToGitHub}>
Push to GitHub
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL)}
</ContextMenuListItem>
)}
<ContextMenuListItem onClick={onDownloadWorkspace}>
Download as .zip
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
</ContextMenuListItem>
</ContextMenu>
);
+12
View File
@@ -1745,5 +1745,17 @@
},
"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"
}
}
-10
View File
@@ -49,7 +49,6 @@ 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;
@@ -295,15 +294,6 @@ 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>
+10
View File
@@ -63,6 +63,16 @@ 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}`,
+15 -24
View File
@@ -5,26 +5,17 @@ type CacheEntry<T> = {
};
class Cache {
private prefix = "app_cache_";
private defaultTTL = 5 * 60 * 1000; // 5 minutes
/**
* 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}`;
}
private cacheMemory: Record<string, string> = {};
/**
* Retrieve the cached data from local storage
* @param key The key to be retrieved from local storage
* @returns The data stored in local storage
* Retrieve the cached data from memory
* @param key The key to be retrieved from memory
* @returns The data stored in memory
*/
public get<T>(key: CacheKey): T | null {
const cachedEntry = localStorage.getItem(this.getKey(key));
const cachedEntry = this.cacheMemory[key];
if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data;
@@ -35,34 +26,34 @@ class Cache {
}
/**
* 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
* 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
* @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 };
localStorage.setItem(this.getKey(key), JSON.stringify(entry));
this.cacheMemory[key] = JSON.stringify(entry);
}
/**
* Remove the data from local storage
* @param key The key to be removed from local storage
* Remove the data from memory
* @param key The key to be removed from memory
* @returns void
*/
public delete(key: CacheKey): void {
localStorage.removeItem(this.getKey(key));
delete this.cacheMemory[key];
}
/**
* Clear all data with the app prefix from local storage
* Clear all data
* @returns void
*/
public clearAll(): void {
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(this.prefix)) localStorage.removeItem(key);
Object.keys(this.cacheMemory).forEach((key) => {
delete this.cacheMemory[key];
});
}
}
@@ -29,6 +29,7 @@ SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can inte
<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>
"""
@@ -163,6 +163,9 @@ 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 #}
+6 -3
View File
@@ -123,6 +123,7 @@ async def run_controller(
if runtime is None:
runtime = create_runtime(config, sid=sid)
await runtime.connect()
event_stream = runtime.event_stream
@@ -188,8 +189,6 @@ async def run_controller(
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
await runtime.connect()
end_states = [
AgentState.FINISHED,
AgentState.REJECTED,
@@ -213,7 +212,11 @@ async def run_controller(
# save trajectories if applicable
if config.trajectories_path is not None:
file_path = os.path.join(config.trajectories_path, sid + '.json')
# 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
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:
+3 -2
View File
@@ -3,6 +3,7 @@ import copy
import json
import os
from abc import abstractmethod
from pathlib import Path
from typing import Callable
from requests.exceptions import ConnectionError
@@ -274,6 +275,6 @@ class Runtime(FileEditRuntimeMixin):
raise NotImplementedError('This method is not implemented in the base class.')
@abstractmethod
def copy_from(self, path: str) -> bytes:
"""Zip all files in the sandbox and return as a stream of bytes."""
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return a path in the local filesystem."""
raise NotImplementedError('This method is not implemented in the base class.')
@@ -1,4 +1,5 @@
import os
from pathlib import Path
import tempfile
import threading
from functools import lru_cache
@@ -604,7 +605,7 @@ class EventStreamRuntime(Runtime):
except requests.Timeout:
raise TimeoutError('List files operation timed out')
def copy_from(self, path: str) -> bytes:
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return as a stream of bytes."""
self._refresh_logs()
try:
@@ -617,8 +618,11 @@ class EventStreamRuntime(Runtime):
stream=True,
timeout=30,
)
data = response.content
return data
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)
except requests.Timeout:
raise TimeoutError('Copy operation timed out')
@@ -1,4 +1,5 @@
import os
from pathlib import Path
import tempfile
import threading
from typing import Callable, Optional
@@ -467,13 +468,18 @@ class RemoteRuntime(Runtime):
assert isinstance(response_json, list)
return response_json
def copy_from(self, path: str) -> bytes:
def copy_from(self, path: str) -> Path:
"""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,
)
return response.content
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)
+14 -15
View File
@@ -1,9 +1,12 @@
import os
import httpx
from github import Github
from github.GithubException import GithubException
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
from openhands.utils.async_utils import call_sync_from_async
GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '').strip()
GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
@@ -101,6 +104,7 @@ 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.
@@ -108,21 +112,16 @@ async def get_github_user(token: str) -> str:
token: GitHub access token
Returns:
Tuple of (login, error_message)
If successful, error_message is None
If failed, login is None and error_message contains the error
github handle of the user
"""
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() 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')
try:
g = Github(token)
user = await call_sync_from_async(g.get_user)
login = user.login
logger.info(f'Successfully retrieved GitHub user: {login}')
return login
except GithubException as e:
logger.error(f'Error making request to GitHub API: {str(e)}')
logger.error(e)
raise
+46 -16
View File
@@ -1,12 +1,13 @@
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
@@ -16,6 +17,7 @@ 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
@@ -27,6 +29,7 @@ with warnings.catch_warnings():
from dotenv import load_dotenv
from fastapi import (
BackgroundTasks,
FastAPI,
HTTPException,
Request,
@@ -34,7 +37,7 @@ from fastapi import (
WebSocket,
status,
)
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBearer
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -60,7 +63,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 import get_sid_from_token, sign_token
from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
@@ -204,12 +207,22 @@ async def attach_session(request: Request, call_next):
response = await call_next(request)
return response
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'},
)
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'},
)
if not request.headers.get('Authorization'):
logger.warning('Missing Authorization header')
@@ -790,20 +803,21 @@ async def security_api(request: Request):
@app.get('/api/zip-directory')
async def zip_current_workspace(request: Request):
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
try:
logger.debug('Zipping workspace')
runtime: Runtime = request.state.conversation.runtime
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,
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',
headers={'Content-Disposition': 'attachment; filename=workspace.zip'},
)
# This will execute after the response is sent (So the file is not deleted before being sent)
background_tasks.add_task(zip_file.unlink)
return response
except Exception as e:
logger.error(f'Error zipping workspace: {e}', exc_info=True)
@@ -863,10 +877,26 @@ 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
Generated
+1640 -1626
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.12.3"
version = "0.13.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -13,6 +13,7 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12"
datasets = "*"
PyGithub = "*"
pandas = "*"
litellm = "^1.51.1"
google-generativeai = "*" # To use litellm with Gemini Pro API
+5 -2
View File
@@ -1,6 +1,7 @@
"""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 (
@@ -586,8 +587,10 @@ 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 in bytes
assert isinstance(result, bytes)
# Result is returned as a path
assert isinstance(result, Path)
result.unlink()
finally:
_close_test_runtime(runtime)