Compare commits

...

26 Commits

Author SHA1 Message Date
chuckbutkus
a05c031e8e Merge branch 'main' into cb/test-v1-fixes 2026-03-13 14:35:43 -04:00
Chuck Butkus
8babc4baae Lint fixes 2026-03-12 22:17:35 -04:00
tofarr
fc6d774f03 Setting default for new users correctly 2026-03-12 21:40:47 -04:00
tofarr
b6b3566182 v1_enabled is now only defaulted from environment variable 2026-03-12 21:40:40 -04:00
openhands
5a50098f8a fix: Override v1_enabled in settings API when DEFAULT_V1_ENABLED is false
The previous commit only set the default for new Settings objects, but
existing user settings in the database would still have v1_enabled=true.

This fix overrides v1_enabled to False in the settings API response when
DEFAULT_V1_ENABLED is set to '0' or 'false', ensuring all users get V0
conversations regardless of their stored settings.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-12 21:38:24 -04:00
openhands
46b17003d8 Add Enterprise SSO login button to V1 login page
The Enterprise SSO login button was missing from the V1 login page, causing
deployments with ENABLE_ENTERPRISE_SSO env var to not show the SSO option.

Changes:
- Add Enterprise SSO button to LoginContent component with FaUserShield icon
- Use existing auth URL generation for enterprise_sso identity provider
- Add showEnterpriseSso flag based on providers_configured prop
- Add handleEnterpriseSsoAuth handler function
- Add comprehensive test cases for Enterprise SSO button functionality

To enable Enterprise SSO in OpenHands-Cloud helm chart deployments, add the
following to your values.yaml:

  env:
    ENABLE_ENTERPRISE_SSO: "true"
2026-03-12 21:34:21 -04:00
Chuck Butkus
d2479b07b8 Add default initial budget for teams/users 2026-03-12 21:28:12 -04:00
chuckbutkus
556b2e31a9 Merge branch 'main' into feature/aws-shared-event-service 2026-03-12 20:52:52 -04:00
chuckbutkus
ec3c7467ac Merge branch 'main' into feature/aws-shared-event-service 2026-03-03 18:17:13 -05:00
chuckbutkus
b7e1e4c3db Merge branch 'main' into feature/aws-shared-event-service 2026-03-03 14:28:40 -05:00
chuckbutkus
4fa32efe00 Merge branch 'main' into feature/aws-shared-event-service 2026-03-03 12:04:26 -05:00
Chuck Butkus
a05e9c2cc0 Revert "fix: use timezone-naive datetime for accepted_tos to match TIMESTAMP WITHOUT TIME ZONE column"
This reverts commit b2adb60723.
2026-03-03 02:06:13 -05:00
openhands
496b23243e Refactor: Extract shared get_storage_provider() utility
- Add StorageProvider enum and get_storage_provider() function in
  openhands/utils/environment.py
- Update enterprise/server/sharing/shared_event_router.py to use the
  shared utility instead of duplicated logic
- Update openhands/app_server/config.py to use the shared utility
- Add comprehensive tests for get_storage_provider() function

This eliminates code duplication between the two files, making it easier
to add new storage providers (like Azure) in the future.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 06:49:05 +00:00
openhands
b2adb60723 fix: use timezone-naive datetime for accepted_tos to match TIMESTAMP WITHOUT TIME ZONE column
The accept_tos endpoint was passing a timezone-aware datetime (datetime.now(timezone.utc))
to a PostgreSQL TIMESTAMP WITHOUT TIME ZONE column, causing asyncpg to fail with:
'can't subtract offset-naive and offset-aware datetimes'

Fixed by stripping the timezone info with .replace(tzinfo=None) to match the database
column type.
2026-03-03 05:56:51 +00:00
Chuck Butkus
81f7993fd3 Update agent server version 2026-03-03 00:19:58 -05:00
chuckbutkus
e2bf717d2e Merge branch 'main' into feature/aws-shared-event-service 2026-03-02 22:37:52 -05:00
Chuck Butkus
9f94f0a047 Lint fix 2026-03-02 22:35:49 -05:00
Chuck Butkus
ca5400f116 Fix test 2026-03-02 22:32:40 -05:00
Chuck Butkus
d4861fc221 Fixes 2026-03-02 22:30:30 -05:00
openhands
16db9086a3 Add test coverage for AWS shared event service changes
- Add tests for AwsEventService (_load_event, _store_event, _search_paths, injector)
- Add tests for config_from_env AWS provider selection logic
- Add tests for get_shared_event_service_injector function
- Fix config.py to check AWS provider first before GCP (provider precedence bug)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 03:01:42 +00:00
Chuck Butkus
e23238d6cc Fix event store check 2026-03-02 21:46:23 -05:00
openhands
0c3ed2ab9c Auto-detect shared event storage provider from FILE_STORE
If SHARED_EVENT_STORAGE_PROVIDER is not set, fall back to FILE_STORE:
- FILE_STORE=s3 -> use AWS S3 for shared events
- FILE_STORE=google_cloud -> use Google Cloud Storage (default)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 00:04:29 +00:00
openhands
c28bcf9277 Add AWS S3 support for main event storage
Set FILE_STORE=s3 and FILE_STORE_PATH=<bucket-name> to use AWS S3.
Uses role-based authentication (no explicit credentials needed).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 23:40:35 +00:00
openhands
6bbcbf7340 Make shared event storage provider configurable via environment variable
Set SHARED_EVENT_STORAGE_PROVIDER to:
- 'aws' for AWS S3
- 'gcp' for Google Cloud Storage (default)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 22:05:47 +00:00
openhands
08d173f55a Fix mypy type error: add runtime check for bucket_name
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 21:11:25 +00:00
openhands
e9be62f767 Add AwsSharedEventService for shared conversations
- Add AwsEventService in openhands/app_server/event for S3-based event storage
- Add AwsSharedEventService in enterprise/server/sharing mirroring GoogleCloud implementation
- Use role-based authentication (no explicit credentials needed)
- Bucket specified via FILE_STORE_PATH environment variable
- Add comprehensive unit tests (16 tests)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 21:06:21 +00:00
7 changed files with 129 additions and 1 deletions

View File

@@ -77,6 +77,8 @@ PERMITTED_CORS_ORIGINS = [
)
]
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
def build_litellm_proxy_model_path(model_name: str) -> str:
"""Build the LiteLLM proxy model path based on model name.

View File

@@ -29,6 +29,8 @@ KEY_VERIFICATION_TIMEOUT = 5.0
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
UNLIMITED_BUDGET_SETTING = 1000000000.0
DEFAULT_INITIAL_BUDGET = float(os.getenv('DEFAULT_INITIAL_BUDGET', '0.0'))
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
"""Generate the key alias for OpenHands Cloud managed keys."""
@@ -101,7 +103,7 @@ class LiteLlmManager:
) as client:
# Check if team already exists and get its budget
# New users joining existing orgs should inherit the team's budget
team_budget = 0.0
team_budget: float = DEFAULT_INITIAL_BUDGET
try:
existing_team = await LiteLlmManager._get_team(client, org_id)
if existing_team:

View File

@@ -6,6 +6,7 @@ from typing import Optional
from uuid import UUID
from server.constants import (
DEFAULT_V1_ENABLED,
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
get_default_litellm_model,
@@ -36,6 +37,8 @@ class OrgStore:
org = Org(**kwargs)
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
if org.v1_enabled is None:
org.v1_enabled = DEFAULT_V1_ENABLED
session.add(org)
await session.commit()
await session.refresh(org)

View File

@@ -7,6 +7,7 @@ from uuid import UUID
from server.auth.token_manager import TokenManager
from server.constants import (
DEFAULT_V1_ENABLED,
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
PERSONAL_WORKSPACE_VERSION_TO_MODEL,
@@ -892,6 +893,8 @@ class UserStore:
language='en', enable_proactive_conversation_starters=True
)
default_settings.v1_enabled = DEFAULT_V1_ENABLED
from storage.lite_llm_manager import LiteLlmManager
settings = await LiteLlmManager.create_entries(

View File

@@ -15,6 +15,7 @@ vi.mock("#/hooks/use-auth-url", () => ({
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
bitbucket_data_center:
"https://bitbucket-dc.example.com/site/oauth2/authorize",
enterprise_sso: "https://auth.example.com/realms/test/protocol/openid-connect/auth",
};
if (config.appMode === "saas") {
return urls[config.identityProvider] || null;
@@ -117,6 +118,74 @@ describe("LoginContent", () => {
).not.toBeInTheDocument();
});
it("should display Enterprise SSO button when configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["enterprise_sso"]}
/>
</MemoryRouter>,
);
expect(
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
).toBeInTheDocument();
});
it("should display Enterprise SSO alongside other providers when all configured", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["github", "gitlab", "bitbucket", "enterprise_sso"]}
/>
</MemoryRouter>,
);
expect(
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
).toBeInTheDocument();
});
it("should redirect to Enterprise SSO auth URL when Enterprise SSO button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://auth.example.com/realms/test/protocol/openid-connect/auth";
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
authUrl="https://auth.example.com"
providersConfigured={["enterprise_sso"]}
/>
</MemoryRouter>,
);
const enterpriseSsoButton = screen.getByRole("button", {
name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i,
});
await user.click(enterpriseSsoButton);
await waitFor(() => {
expect(window.location.href).toContain(mockUrl);
});
});
it("should display message when no providers are configured", () => {
render(
<MemoryRouter>

View File

@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { FaUserShield } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
@@ -65,6 +66,12 @@ export function LoginContent({
authUrl,
});
const enterpriseSsoAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "enterprise_sso",
authUrl,
});
const handleAuthRedirect = async (
redirectUrl: string,
provider: Provider,
@@ -127,6 +134,12 @@ export function LoginContent({
}
};
const handleEnterpriseSsoAuth = () => {
if (enterpriseSsoAuthUrl) {
handleAuthRedirect(enterpriseSsoAuthUrl, "enterprise_sso");
}
};
const showGithub =
providersConfigured &&
providersConfigured.length > 0 &&
@@ -143,6 +156,10 @@ export function LoginContent({
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("bitbucket_data_center");
const showEnterpriseSso =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("enterprise_sso");
const noProvidersConfigured =
!providersConfigured || providersConfigured.length === 0;
@@ -261,6 +278,19 @@ export function LoginContent({
</span>
</button>
)}
{showEnterpriseSso && (
<button
type="button"
onClick={handleEnterpriseSsoAuth}
className={`${buttonBaseClasses} bg-[#374151] text-white`}
>
<FaUserShield size={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</span>
</button>
)}
</>
)}
</div>

View File

@@ -99,6 +99,7 @@ async def load_settings(
settings_with_token_data.llm_api_key = None
settings_with_token_data.search_api_key = None
settings_with_token_data.sandbox_api_key = None
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')
@@ -113,6 +114,24 @@ async def load_settings(
)
@app.post(
'/reset-settings',
responses={
410: {
'description': 'Reset settings functionality has been removed',
'model': dict,
}
},
)
async def reset_settings() -> JSONResponse:
"""Resets user settings. (Deprecated)"""
logger.warning('Deprecated endpoint /api/reset-settings called by user')
return JSONResponse(
status_code=status.HTTP_410_GONE,
content={'error': 'Reset settings functionality has been removed.'},
)
async def store_llm_settings(
settings: Settings, existing_settings: Settings
) -> Settings: