Compare commits

..

2 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
22 changed files with 93 additions and 133 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.12-nikolaik
## Develop inside Docker container
+3 -3
View File
@@ -38,15 +38,15 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker run -it --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13
docker.all-hands.dev/all-hands-ai/openhands:0.12
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
python -m openhands.core.cli
```
+2 -2
View File
@@ -44,7 +44,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -53,6 +53,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+3 -3
View File
@@ -11,15 +11,15 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13
docker.all-hands.dev/all-hands-ai/openhands:0.12
```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
+1 -5
View File
@@ -22,8 +22,7 @@ describe("getSettings", () => {
.mockReturnValueOnce("language_value")
.mockReturnValueOnce("api_key")
.mockReturnValueOnce("true")
.mockReturnValueOnce("invariant")
.mockReturnValueOnce("true");
.mockReturnValueOnce("invariant");
const settings = getSettings();
@@ -35,7 +34,6 @@ describe("getSettings", () => {
LLM_API_KEY: "api_key",
CONFIRMATION_MODE: true,
SECURITY_ANALYZER: "invariant",
ENABLE_BROWSING: true,
});
});
@@ -60,7 +58,6 @@ describe("getSettings", () => {
LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL,
CONFIRMATION_MODE: DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: DEFAULT_SETTINGS.SECURITY_ANALYZER,
ENABLE_BROWSING: DEFAULT_SETTINGS.ENABLE_BROWSING,
});
});
});
@@ -75,7 +72,6 @@ describe("saveSettings", () => {
LLM_API_KEY: "some_key",
CONFIRMATION_MODE: true,
SECURITY_ANALYZER: "invariant",
ENABLE_BROWSING: true,
};
saveSettings(settings);
+27 -6
View File
@@ -8,6 +8,7 @@ describe("Cache", () => {
const testTTL = 1000; // 1 second
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
});
@@ -15,7 +16,17 @@ describe("Cache", () => {
vi.useRealTimers();
});
it("gets data from memory if not expired", () => {
it("sets data in localStorage with expiration", () => {
cache.set(testKey, testData, testTTL);
const cachedEntry = JSON.parse(
localStorage.getItem(`app_cache_${testKey}`) || "",
);
expect(cachedEntry.data).toEqual(testData);
expect(cachedEntry.expiration).toBeGreaterThan(Date.now());
});
it("gets data from localStorage if not expired", () => {
cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData);
@@ -28,6 +39,7 @@ describe("Cache", () => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("returns null if cached data is expired", () => {
@@ -35,19 +47,28 @@ describe("Cache", () => {
vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("deletes data from memory", () => {
it("deletes data from localStorage", () => {
cache.set(testKey, testData, testTTL);
cache.delete(testKey);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("clears all data with the app prefix from memory", () => {
it("clears all data with the app prefix from localStorage", () => {
cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll();
expect(cache.get(testKey)).toBeNull();
expect(cache.get("anotherKey")).toBeNull();
expect(localStorage.length).toBe(0);
});
it("does not retrieve non-prefixed data from localStorage when clearing", () => {
localStorage.setItem("nonPrefixedKey", "should remain");
cache.set(testKey, testData, testTTL);
cache.clearAll();
expect(localStorage.getItem("nonPrefixedKey")).toBe("should remain");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"private": true,
"type": "module",
"engines": {
@@ -120,4 +120,4 @@
"public"
]
}
}
}
+1 -20
View File
@@ -1,24 +1,16 @@
import { useTranslation } from "react-i18next";
import { IoIosGlobe } from "react-icons/io";
import { useSelector } from "react-redux";
import { Switch } from "@nextui-org/react";
import clsx from "clsx";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { getSettings, saveSettings } from "#/services/settings";
function BrowserPanel() {
const { t } = useTranslation();
const settings = getSettings();
const { url, screenshotSrc } = useSelector(
(state: RootState) => state.browser,
);
const handleBrowserToggle = (enabled: boolean) => {
saveSettings({ ...settings, ENABLE_BROWSING: enabled });
};
const imgSrc =
screenshotSrc && screenshotSrc.startsWith("data:image/png;base64,")
? screenshotSrc
@@ -30,18 +22,7 @@ function BrowserPanel() {
{url}
</div>
<div className="overflow-y-auto grow scrollbar-hide rounded-xl">
{!settings.ENABLE_BROWSING ? (
<div className="flex flex-col items-center h-full justify-center gap-4 text-center px-4">
<IoIosGlobe size={100} />
<div>
<p className="text-lg mb-2">Browser Control is Disabled</p>
<p className="text-sm text-neutral-500">
Browser control is an experimental feature that allows the AI assistant to interact with web browsers.
To enable it, go to Settings and enable Browser Control.
</p>
</div>
</div>
) : screenshotSrc ? (
{screenshotSrc ? (
<img
src={imgSrc}
style={{ objectFit: "contain", width: "100%", height: "auto" }}
@@ -249,25 +249,6 @@ export function SettingsForm({
</p>
</fieldset>
<Switch
isDisabled={disabled}
name="enable-browsing"
defaultSelected={settings.ENABLE_BROWSING}
classNames={{
thumb: clsx(
"bg-[#5D5D5D] w-3 h-3",
"group-data-[selected=true]:bg-white",
),
wrapper: clsx(
"border border-[#D4D4D4] bg-white px-[6px] w-12 h-6",
"group-data-[selected=true]:border-transparent group-data-[selected=true]:bg-[#4465DB]",
),
label: "text-[#A3A3A3] text-xs",
}}
>
Browser Control (Experimental)
</Switch>
{showAdvancedOptions && (
<fieldset
data-testid="agent-selector"
-3
View File
@@ -50,13 +50,11 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
let baseUrl: string | undefined;
let confirmationMode = false;
let securityAnalyzer: string | undefined;
let enableBrowsing = true;
if (isUsingAdvancedOptions) {
customModel = formData.get("custom-model")?.toString();
baseUrl = formData.get("base-url")?.toString();
confirmationMode = keys.includes("confirmation-mode");
enableBrowsing = keys.includes("enable-browsing");
if (confirmationMode) {
// only set securityAnalyzer if confirmationMode is enabled
securityAnalyzer = formData.get("security-analyzer")?.toString();
@@ -82,7 +80,6 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_BROWSING: enableBrowsing,
};
saveSettings(settings);
-10
View File
@@ -63,16 +63,6 @@ export async function request(
} catch (e) {
onFail(`Error fetching ${url}`);
}
if (response?.status === 401) {
await request(
"/api/authenticate",
{
method: "POST",
},
true,
);
return request(url, options, disableToast, returnResponse, maxRetries - 1);
}
if (response?.status && response?.status >= 400) {
onFail(
`${response.status} error while fetching ${url}: ${response?.statusText}`,
-4
View File
@@ -8,7 +8,6 @@ export type Settings = {
LLM_API_KEY: string;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
ENABLE_BROWSING: boolean;
};
export const DEFAULT_SETTINGS: Settings = {
@@ -19,7 +18,6 @@ export const DEFAULT_SETTINGS: Settings = {
LLM_API_KEY: "",
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
ENABLE_BROWSING: false,
};
const validKeys = Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[];
@@ -73,7 +71,6 @@ export const getSettings = (): Settings => {
const apiKey = localStorage.getItem("LLM_API_KEY");
const confirmationMode = localStorage.getItem("CONFIRMATION_MODE") === "true";
const securityAnalyzer = localStorage.getItem("SECURITY_ANALYZER");
const enableBrowsing = localStorage.getItem("ENABLE_BROWSING") === "true"; // Default to false if not set
return {
LLM_MODEL: model || DEFAULT_SETTINGS.LLM_MODEL,
@@ -83,7 +80,6 @@ export const getSettings = (): Settings => {
LLM_API_KEY: apiKey || DEFAULT_SETTINGS.LLM_API_KEY,
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
ENABLE_BROWSING: enableBrowsing,
};
};
+24 -15
View File
@@ -5,17 +5,26 @@ type CacheEntry<T> = {
};
class Cache {
private prefix = "app_cache_";
private defaultTTL = 5 * 60 * 1000; // 5 minutes
private cacheMemory: Record<string, string> = {};
/**
* Generate a unique key with prefix for local storage
* @param key The key to be stored in local storage
* @returns The unique key with prefix
*/
private getKey(key: CacheKey): string {
return `${this.prefix}${key}`;
}
/**
* Retrieve the cached data from memory
* @param key The key to be retrieved from memory
* @returns The data stored in memory
* Retrieve the cached data from local storage
* @param key The key to be retrieved from local storage
* @returns The data stored in local storage
*/
public get<T>(key: CacheKey): T | null {
const cachedEntry = this.cacheMemory[key];
const cachedEntry = localStorage.getItem(this.getKey(key));
if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data;
@@ -26,34 +35,34 @@ class Cache {
}
/**
* Store the data in memory with expiration
* @param key The key to be stored in memory
* @param data The data to be stored in memory
* Store the data in local storage with expiration
* @param key The key to be stored in local storage
* @param data The data to be stored in local storage
* @param ttl The time to live for the data in milliseconds
* @returns void
*/
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
const expiration = Date.now() + ttl;
const entry: CacheEntry<T> = { data, expiration };
this.cacheMemory[key] = JSON.stringify(entry);
localStorage.setItem(this.getKey(key), JSON.stringify(entry));
}
/**
* Remove the data from memory
* @param key The key to be removed from memory
* Remove the data from local storage
* @param key The key to be removed from local storage
* @returns void
*/
public delete(key: CacheKey): void {
delete this.cacheMemory[key];
localStorage.removeItem(this.getKey(key));
}
/**
* Clear all data
* Clear all data with the app prefix from local storage
* @returns void
*/
public clearAll(): void {
Object.keys(this.cacheMemory).forEach((key) => {
delete this.cacheMemory[key];
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(this.prefix)) localStorage.removeItem(key);
});
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ class AgentConfig:
"""
function_calling: bool = True
codeact_enable_browsing: bool = False
codeact_enable_browsing: bool = True
codeact_enable_llm_editor: bool = False
codeact_enable_jupyter: bool = True
micro_agent_name: str | None = None
-1
View File
@@ -47,4 +47,3 @@ class ConfigType(str, Enum):
WORKSPACE_MOUNT_PATH = 'WORKSPACE_MOUNT_PATH'
WORKSPACE_MOUNT_PATH_IN_SANDBOX = 'WORKSPACE_MOUNT_PATH_IN_SANDBOX'
WORKSPACE_MOUNT_REWRITE = 'WORKSPACE_MOUNT_REWRITE'
ENABLE_BROWSING = 'ENABLE_BROWSING'
+18 -27
View File
@@ -2,12 +2,10 @@ import asyncio
import os
import re
import tempfile
import time
import uuid
import warnings
from contextlib import asynccontextmanager
import jwt
import requests
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
@@ -17,7 +15,6 @@ from openhands.server.data_models.feedback import FeedbackDataModel, store_feedb
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user,
)
from openhands.storage import get_file_store
@@ -63,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.auth import get_sid_from_token, sign_token
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
@@ -207,21 +204,23 @@ async def attach_session(request: Request, call_next):
response = await call_next(request)
return response
user_verifier = UserVerifier()
if user_verifier.is_active():
signed_token = request.cookies.get('github_auth')
if not signed_token:
# 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'},
)
try:
jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
except Exception as e:
logger.warning(f'Invalid token: {e}')
# 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': 'Invalid token'},
content={'error': 'Not authenticated'},
)
if not request.headers.get('Authorization'):
@@ -877,25 +876,17 @@ async def authenticate(request: Request):
content={'error': 'Not authorized via GitHub waitlist'},
)
# Create a signed JWT token with 1-hour expiration
cookie_data = {
'github_token': token,
'exp': int(time.time()) + 3600, # 1 hour expiration
}
signed_token = sign_token(cookie_data, config.jwt_secret)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
)
# Set secure cookie with signed token
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=signed_token,
key="github_auth",
value=token,
max_age=3600, # 1 hour in seconds
httponly=True,
secure=True,
samesite='strict',
samesite="strict"
)
return response
+1 -4
View File
@@ -89,10 +89,6 @@ class Session:
ConfigType.SECURITY_ANALYZER, self.config.security.security_analyzer
)
max_iterations = args.get(ConfigType.MAX_ITERATIONS, self.config.max_iterations)
# Get agent config and update browsing setting
agent_config = self.config.get_agent_config(agent_cls)
agent_config.codeact_enable_browsing = args.get('ENABLE_BROWSING', False)
# override default LLM config
default_llm_config = self.config.get_llm_config()
default_llm_config.model = args.get(
@@ -108,6 +104,7 @@ class Session:
# TODO: override other LLM config & agent config groups (#2075)
llm = LLM(config=self.config.get_llm_config_from_agent(agent_cls))
agent_config = self.config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(llm, agent_config)
# Create the agent session
+3 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.13.0"
version = "0.12.3"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -92,6 +92,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -122,6 +123,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"