mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 11:33:50 +08:00
feat: add default_sandbox_spec_id to user settings (#14985)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
co-authored by
openhands
parent
3dafef983f
commit
72140ff39c
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user