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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user