diff --git a/enterprise/integrations/resolver_context.py b/enterprise/integrations/resolver_context.py index 43f5a86430..798622e955 100644 --- a/enterprise/integrations/resolver_context.py +++ b/enterprise/integrations/resolver_context.py @@ -101,3 +101,7 @@ class ResolverUserContext(UserContext): async def get_user_git_info(self) -> UserGitInfo | None: return await self.saas_user_auth.get_user_git_info() + + async def get_default_sandbox_spec_id(self) -> str | None: + user_info = await self.get_user_info() + return user_info.default_sandbox_spec_id diff --git a/enterprise/migrations/versions/126_add_default_sandbox_spec_id.py b/enterprise/migrations/versions/126_add_default_sandbox_spec_id.py new file mode 100644 index 0000000000..be83620f6d --- /dev/null +++ b/enterprise/migrations/versions/126_add_default_sandbox_spec_id.py @@ -0,0 +1,32 @@ +"""Add default_sandbox_spec_id column to user and user_settings tables. + +Revision ID: 125 +Revises: 124 +Create Date: 2026-06-05 +""" + +from typing import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = '126' +down_revision: str | None = '125' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + 'user', + sa.Column('default_sandbox_spec_id', sa.String(), nullable=True), + ) + op.add_column( + 'user_settings', + sa.Column('default_sandbox_spec_id', sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('user_settings', 'default_sandbox_spec_id') + op.drop_column('user', 'default_sandbox_spec_id') diff --git a/enterprise/storage/user.py b/enterprise/storage/user.py index 165d606e1b..ab047f8ea5 100644 --- a/enterprise/storage/user.py +++ b/enterprise/storage/user.py @@ -40,6 +40,7 @@ class User(Base): git_user_email: Mapped[str | None] = mapped_column(String, nullable=True) git_full_clone: Mapped[bool | None] = mapped_column(nullable=True, default=False) sandbox_grouping_strategy: Mapped[str | None] = mapped_column(String, nullable=True) + default_sandbox_spec_id: Mapped[str | None] = mapped_column(String, nullable=True) disabled_skills: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) llm_profiles: Mapped[dict[str, Any] | None] = mapped_column( EncryptedJSON, nullable=True diff --git a/enterprise/storage/user_settings.py b/enterprise/storage/user_settings.py index 17a2f416fc..9eca2b31f6 100644 --- a/enterprise/storage/user_settings.py +++ b/enterprise/storage/user_settings.py @@ -42,6 +42,7 @@ class UserSettings(Base): String, nullable=True ) sandbox_grouping_strategy: Mapped[str | None] = mapped_column(String, nullable=True) + default_sandbox_spec_id: Mapped[str | None] = mapped_column(String, nullable=True) user_version: Mapped[int] = mapped_column(nullable=False, default=0) accepted_tos: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Deprecated (v0): mcp_config now lives inside AgentSettings on Org / OrgMember. diff --git a/frontend/src/api/sandbox-service/sandbox-service.api.ts b/frontend/src/api/sandbox-service/sandbox-service.api.ts index 6855b5e61d..91ef358671 100644 --- a/frontend/src/api/sandbox-service/sandbox-service.api.ts +++ b/frontend/src/api/sandbox-service/sandbox-service.api.ts @@ -2,7 +2,7 @@ // This file contains API methods for /api/v1/sandboxes endpoints. import { openHands } from "../open-hands-axios"; -import type { V1SandboxInfo } from "./sandbox-service.types"; +import type { V1SandboxInfo, V1SandboxSpecPage } from "./sandbox-service.types"; export class SandboxService { /** @@ -29,6 +29,17 @@ export class SandboxService { return data; } + /** + * Search / list available sandbox specs + * Calls the /api/v1/sandbox-specs/search endpoint + */ + static async searchSandboxSpecs(): Promise { + const { data } = await openHands.get( + `/api/v1/sandbox-specs/search`, + ); + return data; + } + /** * Batch get V1 sandboxes by their IDs * Returns null for any missing sandboxes diff --git a/frontend/src/api/sandbox-service/sandbox-service.types.ts b/frontend/src/api/sandbox-service/sandbox-service.types.ts index c06669ff03..e43da3fed7 100644 --- a/frontend/src/api/sandbox-service/sandbox-service.types.ts +++ b/frontend/src/api/sandbox-service/sandbox-service.types.ts @@ -22,3 +22,16 @@ export interface V1SandboxInfo { exposed_urls: V1ExposedUrl[] | null; created_at: string; } + +export interface V1SandboxSpecInfo { + id: string; + command: string[] | null; + created_at: string; + initial_env: Record; + working_dir: string; +} + +export interface V1SandboxSpecPage { + items: V1SandboxSpecInfo[]; + next_page_id: string | null; +} diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx index ef97275404..869c683992 100644 --- a/frontend/src/components/features/settings/settings-dropdown-input.tsx +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -15,7 +15,7 @@ interface SettingsDropdownInputProps { isDisabled?: boolean; isLoading?: boolean; defaultSelectedKey?: string; - selectedKey?: string; + selectedKey?: string | null; isClearable?: boolean; allowsCustomValue?: boolean; required?: boolean; diff --git a/frontend/src/hooks/query/use-sandbox-specs.ts b/frontend/src/hooks/query/use-sandbox-specs.ts new file mode 100644 index 0000000000..0853bf2173 --- /dev/null +++ b/frontend/src/hooks/query/use-sandbox-specs.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { SandboxService } from "#/api/sandbox-service/sandbox-service.api"; + +export const useSandboxSpecs = () => + useQuery({ + queryKey: ["sandbox-specs"], + queryFn: () => SandboxService.searchSandboxSpecs(), + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index b73326b954..3e62ae4658 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -106,6 +106,7 @@ const normalizeSettingsResponse = (settings: Partial): Settings => { sandbox_grouping_strategy: settings.sandbox_grouping_strategy ?? DEFAULT_SETTINGS.sandbox_grouping_strategy, + default_sandbox_spec_id: settings.default_sandbox_spec_id ?? null, }; }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index dbf61df90b..15fca3315e 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -389,6 +389,8 @@ export enum I18nKey { SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED = "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED", SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS = "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS", SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY = "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY", + SETTINGS$DEFAULT_SANDBOX_SPEC = "SETTINGS$DEFAULT_SANDBOX_SPEC", + SETTINGS$DEFAULT_SANDBOX_SPEC_PLACEHOLDER = "SETTINGS$DEFAULT_SANDBOX_SPEC_PLACEHOLDER", SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY", SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL", SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 2d02809a50..07909c8a90 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -6612,6 +6612,40 @@ "uk": "Додати до будь-якої (використовувати першу доступну пісочницю)", "ca": "Afegeix a qualsevol (usa el primer sandbox disponible)" }, + "SETTINGS$DEFAULT_SANDBOX_SPEC": { + "en": "Default Sandbox", + "ja": "デフォルトサンドボックス", + "zh-CN": "默认沙盒", + "zh-TW": "預設沙盒", + "ko-KR": "기본 샌드박스", + "de": "Standard-Sandbox", + "no": "Standard sandkasse", + "it": "Sandbox predefinita", + "pt": "Sandbox padrão", + "es": "Sandbox predeterminada", + "ar": "صندوق الرمل الافتراضي", + "fr": "Sandbox par défaut", + "tr": "Varsayılan Sandbox", + "uk": "Стандартна пісочниця", + "ca": "Sandbox per defecte" + }, + "SETTINGS$DEFAULT_SANDBOX_SPEC_PLACEHOLDER": { + "en": "System default", + "ja": "システムデフォルト", + "zh-CN": "系统默认", + "zh-TW": "系統預設", + "ko-KR": "시스템 기본값", + "de": "Systemstandard", + "no": "Systemstandard", + "it": "Predefinito di sistema", + "pt": "Padrão do sistema", + "es": "Predeterminado del sistema", + "ar": "الافتراضي للنظام", + "fr": "Par défaut du système", + "tr": "Sistem varsayılanı", + "uk": "Системовий стандарт", + "ca": "Per defecte del sistema" + }, "SETTINGS$SEARCH_API_KEY": { "en": "Search API Key (Tavily)", "ja": "検索APIキー (Tavily)", diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index 8890d80d5f..e4b0731f2e 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -25,6 +25,7 @@ import { SandboxGroupingStrategyOptions, } from "#/types/settings"; import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { useSandboxSpecs } from "#/hooks/query/use-sandbox-specs"; export const clientLoader = createPermissionGuard( "manage_application_settings", @@ -37,6 +38,8 @@ function AppSettingsScreen() { const { mutate: saveSettings, isPending } = useSaveSettings(); const { data: settings, isLoading } = useSettings(); const { data: config } = useConfig(); + const { data: sandboxSpecsPage, isLoading: sandboxSpecsLoading } = + useSandboxSpecs(); const [languageInputHasChanged, setLanguageInputHasChanged] = React.useState(false); @@ -60,6 +63,11 @@ function AppSettingsScreen() { ] = React.useState(false); const [selectedSandboxGroupingStrategy, setSelectedSandboxGroupingStrategy] = React.useState(null); + const [sandboxSpecIdHasChanged, setSandboxSpecIdHasChanged] = + React.useState(false); + const [selectedSandboxSpecId, setSelectedSandboxSpecId] = React.useState< + string | null | undefined + >(undefined); const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] = React.useState(false); const [gitUserNameHasChanged, setGitUserNameHasChanged] = @@ -93,6 +101,11 @@ function AppSettingsScreen() { settings?.sandbox_grouping_strategy || DEFAULT_SETTINGS.sandbox_grouping_strategy; + const defaultSandboxSpecId = + selectedSandboxSpecId !== undefined + ? selectedSandboxSpecId + : (settings?.default_sandbox_spec_id ?? null); + const maxBudgetPerTaskValue = formData .get("max-budget-per-task-input") ?.toString(); @@ -115,6 +128,7 @@ function AppSettingsScreen() { enable_proactive_conversation_starters: enableProactiveConversations, enable_solvability_analysis: enableSolvabilityAnalysis, sandbox_grouping_strategy: sandboxGroupingStrategy, + default_sandbox_spec_id: defaultSandboxSpecId, max_budget_per_task: maxBudgetPerTask, git_user_name: gitUserName, git_user_email: gitUserEmail, @@ -136,6 +150,8 @@ function AppSettingsScreen() { setProactiveConversationsSwitchHasChanged(false); setSandboxGroupingStrategyHasChanged(false); setSelectedSandboxGroupingStrategy(null); + setSandboxSpecIdHasChanged(false); + setSelectedSandboxSpecId(undefined); setMaxBudgetPerTaskHasChanged(false); setGitUserNameHasChanged(false); setGitUserEmailHasChanged(false); @@ -193,6 +209,13 @@ function AppSettingsScreen() { setSandboxGroupingStrategyHasChanged(newStrategy !== currentStrategy); }; + const handleSandboxSpecIdChange = (key: React.Key | null) => { + const newSpecId = key?.toString() ?? null; + setSelectedSandboxSpecId(newSpecId); + const currentSpecId = settings?.default_sandbox_spec_id ?? null; + setSandboxSpecIdHasChanged(newSpecId !== currentSpecId); + }; + const checkIfMaxBudgetPerTaskHasChanged = (value: string) => { const newValue = parseMaxBudgetPerTask(value); const currentValue = settings?.max_budget_per_task; @@ -221,6 +244,7 @@ function AppSettingsScreen() { !proactiveConversationsSwitchHasChanged && !solvabilityAnalysisSwitchHasChanged && !sandboxGroupingStrategyHasChanged && + !sandboxSpecIdHasChanged && !maxBudgetPerTaskHasChanged && !gitUserNameHasChanged && !gitUserEmailHasChanged && @@ -303,6 +327,26 @@ function AppSettingsScreen() { wrapperClassName="w-full max-w-[680px]" /> + ({ + key: spec.id, + label: spec.id, + }))} + selectedKey={ + selectedSandboxSpecId !== undefined + ? selectedSandboxSpecId + : settings.default_sandbox_spec_id + } + isClearable + isLoading={sandboxSpecsLoading} + onSelectionChange={handleSandboxSpecIdChange} + wrapperClassName="w-full max-w-[680px]" + /> + {!settings?.v1_enabled && ( | null; sandbox_grouping_strategy?: SandboxGroupingStrategy; + default_sandbox_spec_id?: string | null; }; diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index 88d6e95364..63fc7387f6 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -33,7 +33,10 @@ from openhands.app_server.sandbox.sandbox_service import ( SandboxService, SandboxServiceInjector, ) -from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService +from openhands.app_server.sandbox.sandbox_spec_service import ( + SandboxSpecService, + resolve_sandbox_spec, +) from openhands.app_server.services.injector import InjectorState from openhands.app_server.utils.docker_utils import ( replace_localhost_hostname_for_docker, @@ -103,6 +106,7 @@ class DockerSandboxService(SandboxService): startup_grace_seconds: int = STARTUP_GRACE_SECONDS use_host_network: bool = False kvm_enabled: bool = False + default_sandbox_spec_id: str | None = None def _find_unused_port(self) -> int: """Find an unused port on the host machine.""" @@ -394,15 +398,12 @@ class DockerSandboxService(SandboxService): # Enforce sandbox limits by cleaning up old sandboxes await self.pause_old_sandboxes(self.max_num_sandboxes - 1) - if sandbox_spec_id is None: - sandbox_spec = await self.sandbox_spec_service.get_default_sandbox_spec() - else: - sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec( - sandbox_spec_id - ) - if sandbox_spec_maybe is None: - raise ValueError('Sandbox Spec not found') - sandbox_spec = sandbox_spec_maybe + sandbox_spec = await resolve_sandbox_spec( + sandbox_spec_id, + self.default_sandbox_spec_id, + self.sandbox_spec_service, + _logger, + ) # Generate a sandbox id if none was provided if sandbox_id is None: diff --git a/openhands/app_server/sandbox/dynamic_remote_sandbox_spec_service.py b/openhands/app_server/sandbox/dynamic_remote_sandbox_spec_service.py index a859a7860b..4fca8e997d 100644 --- a/openhands/app_server/sandbox/dynamic_remote_sandbox_spec_service.py +++ b/openhands/app_server/sandbox/dynamic_remote_sandbox_spec_service.py @@ -7,7 +7,7 @@ from typing import AsyncGenerator import httpx from fastapi import Request -from pydantic import Field +from pydantic import Field, PrivateAttr from openhands.app_server.errors import SandboxError from openhands.app_server.sandbox.sandbox_spec_models import ( @@ -133,12 +133,18 @@ class DynamicRemoteSandboxSpecServiceInjector(SandboxSpecServiceInjector): description='Seconds to cache the warm runtime config list before re-fetching.', ) + # Shared across all requests — the injector is a long-lived singleton in the + # global config, so this attribute persists and the TTL cache actually works. + _service: DynamicRemoteSandboxSpecService | None = PrivateAttr(default=None) + async def inject( self, state: InjectorState, request: Request | None = None ) -> AsyncGenerator[SandboxSpecService, None]: - yield DynamicRemoteSandboxSpecService( - api_url=self.api_url, - api_key=self.api_key, - default_spec_name=self.default_spec_name, - cache_ttl_seconds=self.cache_ttl_seconds, - ) + if self._service is None: + self._service = DynamicRemoteSandboxSpecService( + api_url=self.api_url, + api_key=self.api_key, + default_spec_name=self.default_spec_name, + cache_ttl_seconds=self.cache_ttl_seconds, + ) + yield self._service diff --git a/openhands/app_server/sandbox/process_sandbox_service.py b/openhands/app_server/sandbox/process_sandbox_service.py index 5ef92316ea..8272793021 100644 --- a/openhands/app_server/sandbox/process_sandbox_service.py +++ b/openhands/app_server/sandbox/process_sandbox_service.py @@ -37,7 +37,10 @@ from openhands.app_server.sandbox.sandbox_service import ( SandboxServiceInjector, ) from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo -from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService +from openhands.app_server.sandbox.sandbox_spec_service import ( + SandboxSpecService, + resolve_sandbox_spec, +) from openhands.app_server.services.injector import InjectorState from openhands.app_server.utils.docker_utils import ( replace_localhost_hostname_for_docker, @@ -83,6 +86,7 @@ class ProcessSandboxService(SandboxService): agent_server_module: str health_check_path: str httpx_client: httpx.AsyncClient + default_sandbox_spec_id: str | None = None def __post_init__(self): """Initialize the service after dataclass creation.""" @@ -305,15 +309,12 @@ class ProcessSandboxService(SandboxService): ) -> SandboxInfo: """Start a new sandbox.""" # Get sandbox spec - if sandbox_spec_id is None: - sandbox_spec = await self.sandbox_spec_service.get_default_sandbox_spec() - else: - sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec( - sandbox_spec_id - ) - if sandbox_spec_maybe is None: - raise ValueError('Sandbox Spec not found') - sandbox_spec = sandbox_spec_maybe + sandbox_spec = await resolve_sandbox_spec( + sandbox_spec_id, + self.default_sandbox_spec_id, + self.sandbox_spec_service, + _logger, + ) # Generate unique sandbox ID and session API key # Use provided sandbox_id if available, otherwise generate a random one @@ -462,6 +463,7 @@ class ProcessSandboxServiceInjector(SandboxServiceInjector): get_user_context(state, request) as user_context, ): user_id = await user_context.get_user_id() + default_sandbox_spec_id = await user_context.get_default_sandbox_spec_id() yield ProcessSandboxService( user_id=user_id, sandbox_spec_service=sandbox_spec_service, @@ -471,4 +473,5 @@ class ProcessSandboxServiceInjector(SandboxServiceInjector): agent_server_module=self.agent_server_module, health_check_path=self.health_check_path, httpx_client=httpx_client, + default_sandbox_spec_id=default_sandbox_spec_id, ) diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py index f93d2576d8..f1e67241d5 100644 --- a/openhands/app_server/sandbox/remote_sandbox_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_service.py @@ -43,7 +43,10 @@ from openhands.app_server.sandbox.sandbox_service import ( SandboxServiceInjector, ) from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo -from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService +from openhands.app_server.sandbox.sandbox_spec_service import ( + SandboxSpecService, + resolve_sandbox_spec, +) from openhands.app_server.services.injector import InjectorState from openhands.app_server.user.specifiy_user_context import ADMIN, USER_CONTEXT_ATTR from openhands.app_server.user.user_context import UserContext @@ -425,17 +428,13 @@ class RemoteSandboxService(SandboxService): await self.pause_old_sandboxes(self.max_num_sandboxes - 1) # Get sandbox spec - if sandbox_spec_id is None: - sandbox_spec = ( - await self.sandbox_spec_service.get_default_sandbox_spec() - ) - else: - sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec( - sandbox_spec_id - ) - if sandbox_spec_maybe is None: - raise ValueError('Sandbox Spec not found') - sandbox_spec = sandbox_spec_maybe + user_default_spec_id = await self.user_context.get_default_sandbox_spec_id() + sandbox_spec = await resolve_sandbox_spec( + sandbox_spec_id, + user_default_spec_id, + self.sandbox_spec_service, + _logger, + ) if sandbox_id is None: sandbox_id = base62.encodebytes(os.urandom(16)) diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index 8cc2c02cb1..51a2bdbf6b 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -1,4 +1,5 @@ import asyncio +import logging import os from abc import ABC, abstractmethod @@ -61,6 +62,42 @@ class SandboxSpecServiceInjector( pass +async def resolve_sandbox_spec( + sandbox_spec_id: str | None, + user_default_spec_id: str | None, + sandbox_spec_service: SandboxSpecService, + logger: logging.Logger, +) -> SandboxSpecInfo: + """Return the SandboxSpecInfo to use for a new sandbox. + + Resolution order: + 1. ``sandbox_spec_id`` (caller-explicit) — not found is a hard error. + 2. ``user_default_spec_id`` (user preference) — if missing, log a warning + and fall back to the system default. + 3. System default (first spec returned by the service). + """ + from_user_default = sandbox_spec_id is None and user_default_spec_id is not None + effective_id = ( + sandbox_spec_id if sandbox_spec_id is not None else user_default_spec_id + ) + + if effective_id is None: + return await sandbox_spec_service.get_default_sandbox_spec() + + spec = await sandbox_spec_service.get_sandbox_spec(effective_id) + if spec is not None: + return spec + + if from_user_default: + logger.warning( + 'User default sandbox spec %r not found; falling back to system default.', + effective_id, + ) + return await sandbox_spec_service.get_default_sandbox_spec() + + raise ValueError(f'Sandbox Spec {effective_id!r} not found') + + def get_agent_server_image() -> str: agent_server_image_repository = os.getenv('AGENT_SERVER_IMAGE_REPOSITORY') agent_server_image_tag = os.getenv('AGENT_SERVER_IMAGE_TAG') diff --git a/openhands/app_server/settings/settings_models.py b/openhands/app_server/settings/settings_models.py index b593ca8835..82cc77ba43 100644 --- a/openhands/app_server/settings/settings_models.py +++ b/openhands/app_server/settings/settings_models.py @@ -138,6 +138,7 @@ class Settings(BaseModel): sandbox_grouping_strategy: SandboxGroupingStrategy = ( SandboxGroupingStrategy.NO_GROUPING ) + default_sandbox_spec_id: str | None = None llm_profiles: LLMProfiles = Field( default_factory=LLMProfiles, description=( diff --git a/openhands/app_server/user/auth_user_context.py b/openhands/app_server/user/auth_user_context.py index 6bfd416d20..70cb3710a6 100644 --- a/openhands/app_server/user/auth_user_context.py +++ b/openhands/app_server/user/auth_user_context.py @@ -145,6 +145,10 @@ class AuthUserContext(UserContext): async def get_user_git_info(self) -> UserGitInfo | None: return await self.user_auth.get_user_git_info() + async def get_default_sandbox_spec_id(self) -> str | None: + user_info = await self.get_user_info() + return user_info.default_sandbox_spec_id + USER_ID_ATTR = 'user_id' diff --git a/openhands/app_server/user/specifiy_user_context.py b/openhands/app_server/user/specifiy_user_context.py index af36e3940a..1957410594 100644 --- a/openhands/app_server/user/specifiy_user_context.py +++ b/openhands/app_server/user/specifiy_user_context.py @@ -47,6 +47,9 @@ class SpecifyUserContext(UserContext): async def get_user_git_info(self) -> UserGitInfo | None: raise NotImplementedError() + async def get_default_sandbox_spec_id(self) -> str | None: + return None + USER_CONTEXT_ATTR = 'user_context' ADMIN = SpecifyUserContext(user_id=None) diff --git a/openhands/app_server/user/user_context.py b/openhands/app_server/user/user_context.py index 9167cbcbb7..52555d9d4d 100644 --- a/openhands/app_server/user/user_context.py +++ b/openhands/app_server/user/user_context.py @@ -81,6 +81,10 @@ class UserContext(ABC): async def get_user_git_info(self) -> UserGitInfo | None: """Get an User Meta""" + @abstractmethod + async def get_default_sandbox_spec_id(self) -> str | None: + """Get the user's preferred default sandbox spec ID, or None to use the global default.""" + async def get_provider_handler(self) -> ProviderHandler: """Get a ProviderHandler bound to this user's provider tokens. diff --git a/tests/unit/app_server/test_docker_sandbox_service.py b/tests/unit/app_server/test_docker_sandbox_service.py index 858352446a..2fb365da26 100644 --- a/tests/unit/app_server/test_docker_sandbox_service.py +++ b/tests/unit/app_server/test_docker_sandbox_service.py @@ -481,7 +481,7 @@ class TestDockerSandboxService: # Execute & Verify with ( patch.object(service, 'pause_old_sandboxes', return_value=[]), - pytest.raises(ValueError, match='Sandbox Spec not found'), + pytest.raises(ValueError, match=r"Sandbox Spec '.*' not found"), ): await service.start_sandbox(sandbox_spec_id='nonexistent') diff --git a/tests/unit/app_server/test_dynamic_remote_sandbox_spec_service.py b/tests/unit/app_server/test_dynamic_remote_sandbox_spec_service.py index 57c7bed2a8..5ec45925e0 100644 --- a/tests/unit/app_server/test_dynamic_remote_sandbox_spec_service.py +++ b/tests/unit/app_server/test_dynamic_remote_sandbox_spec_service.py @@ -384,8 +384,14 @@ class TestDynamicRemoteSandboxSpecServiceInjector: assert svc.default_spec_name == 'nightly' assert svc.cache_ttl_seconds == 30 - async def test_inject_yields_fresh_service_each_call(self): - """Each call to inject() must produce a new service instance.""" + async def test_inject_returns_same_service_each_call(self): + """inject() must return the same service instance on every call. + + The injector is a long-lived singleton. Re-using the same + DynamicRemoteSandboxSpecService instance is what allows the TTL + cache to survive across requests; creating a new instance each time + would silently discard the cache. + """ injector = DynamicRemoteSandboxSpecServiceInjector( api_url='https://rt.example.com', api_key='k' ) @@ -399,4 +405,4 @@ class TestDynamicRemoteSandboxSpecServiceInjector: async for svc in injector.inject(state): second = svc - assert first is not second + assert first is second diff --git a/tests/unit/app_server/test_remote_sandbox_service.py b/tests/unit/app_server/test_remote_sandbox_service.py index 863c3f91bb..6e538677d4 100644 --- a/tests/unit/app_server/test_remote_sandbox_service.py +++ b/tests/unit/app_server/test_remote_sandbox_service.py @@ -446,7 +446,7 @@ class TestSandboxLifecycle: remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[]) # Execute & Verify - with pytest.raises(ValueError, match='Sandbox Spec not found'): + with pytest.raises(ValueError, match=r"Sandbox Spec '.*' not found"): await remote_sandbox_service.start_sandbox('non-existent-spec') @pytest.mark.asyncio