mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a0620b21e | |||
| 3356753f79 | |||
| 4ce3b9094a | |||
| 0a4e196670 | |||
| 8d32a59f55 | |||
| 38b92f4251 | |||
| 88dbe85594 | |||
| f5003a7449 | |||
| a6810fa6ad | |||
| fc05d8d4eb | |||
| 1d6ef0e18e | |||
| dc0e223d1a | |||
| 932de79154 | |||
| fa625fed70 |
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user