Compare commits

..

3 Commits

Author SHA1 Message Date
Robert Brennan dad3c0c0be Merge branch 'main' into rb/fix-client-loader 2024-11-07 11:44:01 -05:00
Robert Brennan 141cced78f remove logspam 2024-11-05 18:40:47 -05:00
Robert Brennan bfaef08d1f remove clientLoader refs 2024-11-05 18:37:06 -05:00
19 changed files with 70 additions and 202 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:
max_iterations: 50
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }}
secrets: inherit
+1 -2
View File
@@ -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
@@ -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]);
});
});
+2 -7
View File
@@ -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);
@@ -65,11 +63,7 @@ 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 {
setWorking(true);
downloadWorkspace().then(
() => setWorking(false),
() => setWorking(false),
);
downloadWorkspace();
} catch (error) {
toast.error("Failed to download workspace");
}
@@ -77,7 +71,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">
{!working && contextMenuIsOpen && (
{contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
@@ -104,11 +98,7 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
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>
);
-12
View File
@@ -1745,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"
}
}
+10
View File
@@ -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>
@@ -29,7 +29,6 @@ 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,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 #}
+3 -6
View File
@@ -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:
+2 -3
View File
@@ -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,5 +1,4 @@
import os
from pathlib import Path
import tempfile
import threading
from typing import Callable, Optional
@@ -468,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
+7 -15
View File
@@ -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')
+20 -59
View File
@@ -1,8 +1,8 @@
import asyncio
import io
import os
import re
import tempfile
import time
import uuid
import warnings
from contextlib import asynccontextmanager
@@ -27,7 +27,6 @@ with warnings.catch_warnings():
from dotenv import load_dotenv
from fastapi import (
BackgroundTasks,
FastAPI,
HTTPException,
Request,
@@ -35,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
@@ -61,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 import get_sid_from_token, sign_token, jwt_encode, jwt_decode
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
@@ -205,34 +204,12 @@ async def attach_session(request: Request, call_next):
response = await call_next(request)
return response
# First check for auth cookie
signed_token = request.cookies.get('github_auth')
github_token = None
if signed_token:
try:
# Verify and decode the JWT token
cookie_data = jwt_decode(signed_token, config.jwt_secret)
github_token = cookie_data.get('github_token')
except Exception:
# If token is invalid or expired, ignore it
github_token = None
# If no valid 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'},
)
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')
@@ -813,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:
@@ -887,25 +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 = jwt_encode(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"
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
)
return response
+2 -5
View File
@@ -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)