feat: add default_sandbox_spec_id to user settings (#14985)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-06-25 11:44:33 -06:00
committed by GitHub
co-authored by openhands
parent 3dafef983f
commit 72140ff39c
26 changed files with 265 additions and 46 deletions
@@ -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
@@ -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')
+1
View File
@@ -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
+1
View File
@@ -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.
@@ -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<V1SandboxSpecPage> {
const { data } = await openHands.get<V1SandboxSpecPage>(
`/api/v1/sandbox-specs/search`,
);
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
@@ -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<string, string>;
working_dir: string;
}
export interface V1SandboxSpecPage {
items: V1SandboxSpecInfo[];
next_page_id: string | null;
}
@@ -15,7 +15,7 @@ interface SettingsDropdownInputProps {
isDisabled?: boolean;
isLoading?: boolean;
defaultSelectedKey?: string;
selectedKey?: string;
selectedKey?: string | null;
isClearable?: boolean;
allowsCustomValue?: boolean;
required?: boolean;
@@ -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
});
+1
View File
@@ -106,6 +106,7 @@ const normalizeSettingsResponse = (settings: Partial<Settings>): Settings => {
sandbox_grouping_strategy:
settings.sandbox_grouping_strategy ??
DEFAULT_SETTINGS.sandbox_grouping_strategy,
default_sandbox_spec_id: settings.default_sandbox_spec_id ?? null,
};
};
+2
View File
@@ -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",
+34
View File
@@ -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)",
+44
View File
@@ -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<SandboxGroupingStrategy | null>(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]"
/>
<SettingsDropdownInput
testId="default-sandbox-spec-input"
name="default-sandbox-spec-input"
label={t(I18nKey.SETTINGS$DEFAULT_SANDBOX_SPEC)}
placeholder={t(I18nKey.SETTINGS$DEFAULT_SANDBOX_SPEC_PLACEHOLDER)}
items={(sandboxSpecsPage?.items ?? []).map((spec) => ({
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 && (
<SettingsInput
testId="max-budget-per-task-input"
+1
View File
@@ -37,6 +37,7 @@ export const DEFAULT_SETTINGS: Settings = {
git_full_clone: false,
v1_enabled: true,
sandbox_grouping_strategy: "NO_GROUPING",
default_sandbox_spec_id: null,
agent_settings_schema: null,
agent_settings: {
schema_version: 1,
+1
View File
@@ -150,4 +150,5 @@ export type Settings = {
conversation_settings_schema?: SettingsSchema | null;
conversation_settings?: Record<string, SettingsValue> | null;
sandbox_grouping_strategy?: SandboxGroupingStrategy;
default_sandbox_spec_id?: string | null;
};
@@ -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:
@@ -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
@@ -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,
)
@@ -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))
@@ -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')
@@ -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=(
@@ -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'
@@ -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)
@@ -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.
@@ -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')
@@ -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
@@ -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