Compare commits

..

14 Commits

Author SHA1 Message Date
Robert Brennan 8a0620b21e Update openhands/server/listen.py 2024-11-08 15:25:24 -05:00
openhands 3356753f79 Add cookie-based GitHub authentication caching
- Add cookie in /authenticate endpoint with 1-hour expiration
- Check for cookie in attach_session middleware before calling GitHub API
- Support cookie auth in WebSocket endpoint
- Maintain backward compatibility with X-GitHub-Token header
2024-11-08 20:23:07 +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
19 changed files with 181 additions and 67 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
+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,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]);
});
});
+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>
@@ -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)
+15 -7
View File
@@ -1,6 +1,7 @@
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
@@ -101,6 +102,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,19 +110,25 @@ 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)
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}')
response.raise_for_status()
user_data = response.json()
login = user_data.get('login')
+38 -17
View File
@@ -1,5 +1,4 @@
import asyncio
import io
import os
import re
import tempfile
@@ -27,6 +26,7 @@ with warnings.catch_warnings():
from dotenv import load_dotenv
from fastapi import (
BackgroundTasks,
FastAPI,
HTTPException,
Request,
@@ -34,7 +34,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
@@ -204,12 +204,24 @@ 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'},
)
# First check for auth cookie
github_token = request.cookies.get('github_auth')
# If no cookie, fall back to header
if not github_token:
github_token = request.headers.get('X-GitHub-Token')
# If no header token either, return error
if not github_token:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authenticated'},
)
# If using header token, verify with GitHub
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')
@@ -790,20 +802,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)
@@ -864,9 +877,17 @@ async def authenticate(request: Request):
)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'})
# Set secure cookie that expires in 1 hour
response.set_cookie(
key="github_auth",
value=token,
max_age=3600, # 1 hour in seconds
httponly=True,
secure=True,
samesite="strict"
)
return response
+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)