From 377489a2c1b539e8a849e98d581b674150cf5550 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 24 Aug 2026 18:23:32 -0400 Subject: [PATCH 1/5] feat(agents): add AgentSandboxSpec entity and CRUD routes Add SandboxSpecInline (provider + provider_config) as the isolation-posture slot on AgentEnvironment that RFC-129 left out of scope. AgentSandboxSpec is a first-class entity with CRUD routes at /sandbox-specs, mirroring the EnvironmentSpec and ComputeSpec pattern from #1379. The sandbox_spec field on AgentEnvironmentInline is ref | inline | None, matching the composition model. No runtime wiring or compiler integration yet; this is the entity layer only. Signed-off-by: Max Dubrinsky --- plugins/nemo-agents/openapi/openapi.yaml | 349 +++++++++++++++++- .../src/nemo_agents_plugin/api/v2/_perms.py | 7 + .../nemo_agents_plugin/api/v2/environments.py | 76 +++- .../src/nemo_agents_plugin/entities.py | 34 +- .../src/nemo_agents_plugin/schema.py | 13 + .../nemo-agents/tests/unit/test_entities.py | 30 ++ .../tests/unit/test_environments_api.py | 63 +++- 7 files changed, 561 insertions(+), 11 deletions(-) diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index 18d6598bcc..af26a0ec7d 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -3433,6 +3433,150 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/sandbox-specs: + post: + tags: + - Agent Sandbox Specs + summary: Create Sandbox Spec + description: Create a new AgentSandboxSpec. + operationId: create_sandbox_spec_apis_agents_v2_workspaces__workspace__sandbox_specs_post + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSandboxSpecRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentSandboxSpec' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agent Sandbox Specs + summary: List Sandbox Specs + description: List AgentSandboxSpecs in the workspace. + operationId: list_sandbox_specs_apis_agents_v2_workspaces__workspace__sandbox_specs_get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + title: Page Size + - name: sort + in: query + required: false + schema: + type: string + default: -created_at + title: Sort + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/NemoListResponse_AgentSandboxSpec_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/agents/v2/workspaces/{workspace}/sandbox-specs/{name}: + get: + tags: + - Agent Sandbox Specs + summary: Get Sandbox Spec + description: Get an AgentSandboxSpec by name. + operationId: get_sandbox_spec_apis_agents_v2_workspaces__workspace__sandbox_specs__name__get + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentSandboxSpec' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agent Sandbox Specs + summary: Delete Sandbox Spec + description: Delete an AgentSandboxSpec by name. + operationId: delete_sandbox_spec_apis_agents_v2_workspaces__workspace__sandbox_specs__name__delete + parameters: + - name: workspace + in: path + required: true + schema: + type: string + title: Workspace + - name: name + in: path + required: true + schema: + type: string + title: Name + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/agents/v2/workspaces/{workspace}/sessions: post: tags: @@ -3980,6 +4124,15 @@ components: title: Environment Spec description: '"workspace/name" ref to an AgentEnvironmentSpec, an inline spec, or None.' + sandbox_spec: + anyOf: + - type: string + title: Reference + description: A reference to SandboxSpecInline. + - $ref: '#/components/schemas/SandboxSpecInline' + title: Sandbox Spec + description: '"workspace/name" ref to an AgentSandboxSpec, an inline spec, + or None.' compute_spec: anyOf: - type: string @@ -4054,7 +4207,8 @@ components: - parent - db_version title: AgentEnvironment - description: 'A composition of an environment spec and a compute spec. + description: 'A composition of an environment spec, a sandbox spec, and a compute + spec. Entity type: ``agent_environment`` @@ -4078,6 +4232,15 @@ components: title: Environment Spec description: '"workspace/name" ref to an AgentEnvironmentSpec, an inline spec, or None.' + sandbox_spec: + anyOf: + - type: string + title: Reference + description: A reference to SandboxSpecInline. + - $ref: '#/components/schemas/SandboxSpecInline' + title: Sandbox Spec + description: '"workspace/name" ref to an AgentSandboxSpec, an inline spec, + or None.' compute_spec: anyOf: - type: string @@ -4089,15 +4252,15 @@ components: or None.' type: object title: AgentEnvironmentInline - description: 'Inline AgentEnvironment - a composition of environment + compute - specs. + description: 'Inline AgentEnvironment - a composition of environment, sandbox, + and compute specs. Each part is a ``ref | inline | None`` union: a ``"workspace/name"`` string references a stored spec entity, an object provides the spec inline, and - ``None`` omits it. (A ``sandbox_spec`` is out of scope for now and omitted.)' + ``None`` omits it.' AgentEnvironmentSpec: properties: description: @@ -4235,6 +4398,96 @@ components: Entity type: ``agent_environment_spec`` Referenced by an AgentEnvironment''s ``environment_spec`` (by name or inline).' + AgentSandboxSpec: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + provider: + type: string + title: Provider + description: Sandbox provider name (e.g. 'openshell', 'opensandbox'). + provider_config: + additionalProperties: true + type: object + title: Provider Config + description: Provider-specific sandbox configuration; the platform does + not interpret these fields. + name: + type: string + title: Name + description: Entity name within the workspace + default: '' + workspace: + type: string + pattern: ^[\w\-\+.@:]+$ + title: Workspace + description: Workspace identifier + project: + title: Project + description: The name of the project associated with this entity. + type: string + id: + type: string + title: Id + readOnly: true + created_at: + title: Created At + readOnly: true + type: string + format: date-time + created_by: + title: Created By + readOnly: true + nullable: true + type: string + updated_at: + title: Updated At + readOnly: true + type: string + format: date-time + updated_by: + title: Updated By + readOnly: true + nullable: true + type: string + entity_id: + type: string + title: Entity Id + description: Alias for id for backwards compatibility. + readOnly: true + parent: + title: Parent + description: Parent entity ID for nested entities. + readOnly: true + type: string + db_version: + type: integer + title: Db Version + description: Database version of the entity for optimistic locking. + readOnly: true + type: object + required: + - provider + - workspace + - id + - created_at + - created_by + - updated_at + - updated_by + - entity_id + - parent + - db_version + title: AgentSandboxSpec + description: 'A reusable sandbox spec (the isolation posture around an agent + run). + + + Entity type: ``agent_sandbox_spec`` + + Referenced by an AgentEnvironment''s ``sandbox_spec`` (by name or inline).' AgentSession: properties: name: @@ -4671,6 +4924,15 @@ components: title: Environment Spec description: '"workspace/name" ref to an AgentEnvironmentSpec, an inline spec, or None.' + sandbox_spec: + anyOf: + - type: string + title: Reference + description: A reference to SandboxSpecInline. + - $ref: '#/components/schemas/SandboxSpecInline' + title: Sandbox Spec + description: '"workspace/name" ref to an AgentSandboxSpec, an inline spec, + or None.' compute_spec: anyOf: - type: string @@ -4764,6 +5026,33 @@ components: - name title: CreateEnvironmentSpecRequest description: Request body for ``POST /v2/workspaces/{workspace}/environment-specs``. + CreateSandboxSpecRequest: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + provider: + type: string + title: Provider + description: Sandbox provider name (e.g. 'openshell', 'opensandbox'). + provider_config: + additionalProperties: true + type: object + title: Provider Config + description: Provider-specific sandbox configuration; the platform does + not interpret these fields. + name: + type: string + title: Name + description: Unique sandbox-spec name within the workspace. + type: object + required: + - provider + - name + title: CreateSandboxSpecRequest + description: Request body for ``POST /v2/workspaces/{workspace}/sandbox-specs``. CreateSessionRequest: properties: deployment_id: @@ -5837,6 +6126,29 @@ components: required: - data title: NemoListResponse_AgentEnvironment_ + NemoListResponse_AgentSandboxSpec_: + properties: + data: + items: + $ref: '#/components/schemas/AgentSandboxSpec' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: "Pagination metadata \u2014 page, page_size, total_results,\ + \ etc." + sort: + title: Sort + description: Sort field applied to this result set (e.g. '-created_at'). + type: string + filter: + title: Filter + description: Filter criteria echoed back from the request. + type: object + required: + - data + title: NemoListResponse_AgentSandboxSpec_ NemoListResponse_AgentSession_: properties: data: @@ -6854,6 +7166,35 @@ components: - config - config_format title: ResolvedAgentConfig + SandboxSpecInline: + properties: + description: + type: string + title: Description + description: Human-readable description. + default: '' + provider: + type: string + title: Provider + description: Sandbox provider name (e.g. 'openshell', 'opensandbox'). + provider_config: + additionalProperties: true + type: object + title: Provider Config + description: Provider-specific sandbox configuration; the platform does + not interpret these fields. + type: object + required: + - provider + title: SandboxSpecInline + description: 'Inline sandbox spec - the isolation posture around an agent run. + + + Provider-specific in its extension fields. ``provider`` names a registered + + sandbox provider (open set, discovered via entry points); ``provider_config`` + + carries provider-specific fields the platform does not interpret.' SessionFilter: additionalProperties: false description: Query filter for ``GET /v2/workspaces/{workspace}/sessions``. diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py index cf7bda0cfd..8b5dfa5b17 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/_perms.py @@ -60,3 +60,10 @@ class ComputeSpecPerms(PermissionSet, namespace="agents.compute-specs"): LIST = perm("List agent compute specs") READ = perm("Read an agent compute spec") DELETE = perm("Delete an agent compute spec") + + +class SandboxSpecPerms(PermissionSet, namespace="agents.sandbox-specs"): + CREATE = perm("Create agent sandbox specs") + LIST = perm("List agent sandbox specs") + READ = perm("Read an agent sandbox spec") + DELETE = perm("Delete an agent sandbox spec") diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py index 6dfead11d7..bdc5b4cc47 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/environments.py @@ -19,20 +19,23 @@ from typing import TypeVar from fastapi import APIRouter, Depends, HTTPException, Query -from nemo_agents_plugin.api.v2._perms import ComputeSpecPerms, EnvironmentPerms, EnvironmentSpecPerms +from nemo_agents_plugin.api.v2._perms import ComputeSpecPerms, EnvironmentPerms, EnvironmentSpecPerms, SandboxSpecPerms from nemo_agents_plugin.api.v2.dependencies import get_entity_client from nemo_agents_plugin.authz import scope -from nemo_agents_plugin.entities import AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec +from nemo_agents_plugin.entities import AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec, AgentSandboxSpec from nemo_agents_plugin.schema import ( ComputeSpecFilter, ComputeSpecPage, CreateComputeSpecRequest, CreateEnvironmentRequest, CreateEnvironmentSpecRequest, + CreateSandboxSpecRequest, EnvironmentFilter, EnvironmentPage, EnvironmentSpecFilter, EnvironmentSpecPage, + SandboxSpecFilter, + SandboxSpecPage, ) from nemo_platform_plugin.api.filters import make_filter_obj_dep from nemo_platform_plugin.authz import CallerKind, path_rule @@ -53,6 +56,7 @@ _environment_filter_dep = make_filter_obj_dep(EnvironmentFilter) _environment_spec_filter_dep = make_filter_obj_dep(EnvironmentSpecFilter) _compute_spec_filter_dep = make_filter_obj_dep(ComputeSpecFilter) +_sandbox_spec_filter_dep = make_filter_obj_dep(SandboxSpecFilter) # --------------------------------------------------------------------------- @@ -74,6 +78,7 @@ async def create_environment( workspace=workspace, description=body.description, environment_spec=body.environment_spec, + sandbox_spec=body.sandbox_spec, compute_spec=body.compute_spec, ) return await _create_entity(entity_client, environment, kind="environment", name=body.name, workspace=workspace) @@ -266,6 +271,73 @@ async def delete_compute_spec( await _delete_entity(entity_client, AgentComputeSpec, name=name, workspace=workspace, kind="compute spec") +# --------------------------------------------------------------------------- +# AgentSandboxSpec +# --------------------------------------------------------------------------- + + +@router.post("/sandbox-specs", response_model=AgentSandboxSpec, status_code=201, tags=["Agent Sandbox Specs"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.CREATE]) +async def create_sandbox_spec( + workspace: str, + body: CreateSandboxSpecRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentSandboxSpec: + """Create a new AgentSandboxSpec.""" + spec = AgentSandboxSpec(**body.model_dump(), workspace=workspace) + return await _create_entity(entity_client, spec, kind="sandbox spec", name=body.name, workspace=workspace) + + +@router.get("/sandbox-specs", response_model=SandboxSpecPage, tags=["Agent Sandbox Specs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.LIST]) +async def list_sandbox_specs( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: SandboxSpecFilter = Depends(_sandbox_spec_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> SandboxSpecPage: + """List AgentSandboxSpecs in the workspace.""" + return await _list_entities( + entity_client, + AgentSandboxSpec, + SandboxSpecPage, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter=filter, + kind="sandbox specs", + ) + + +@router.get("/sandbox-specs/{name}", response_model=AgentSandboxSpec, tags=["Agent Sandbox Specs"]) +@scope.read +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.READ]) +async def get_sandbox_spec( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> AgentSandboxSpec: + """Get an AgentSandboxSpec by name.""" + return await _get_entity(entity_client, AgentSandboxSpec, name=name, workspace=workspace, kind="sandbox spec") + + +@router.delete("/sandbox-specs/{name}", status_code=204, tags=["Agent Sandbox Specs"]) +@scope.write +@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[SandboxSpecPerms.DELETE]) +async def delete_sandbox_spec( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + """Delete an AgentSandboxSpec by name.""" + await _delete_entity(entity_client, AgentSandboxSpec, name=name, workspace=workspace, kind="sandbox spec") + + # --------------------------------------------------------------------------- # Shared CRUD helpers # --------------------------------------------------------------------------- diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index a60f5c4da1..94bf15f8a7 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -197,12 +197,28 @@ class EnvironmentSpecInline(BaseModel): ) +class SandboxSpecInline(BaseModel): + """Inline sandbox spec - the isolation posture around an agent run. + + Provider-specific in its extension fields. ``provider`` names a registered + sandbox provider (open set, discovered via entry points); ``provider_config`` + carries provider-specific fields the platform does not interpret. + """ + + description: str = Field(default="", description="Human-readable description.") + provider: str = Field(description="Sandbox provider name (e.g. 'openshell', 'opensandbox').") + provider_config: dict[str, Any] = Field( + default_factory=dict, + description="Provider-specific sandbox configuration; the platform does not interpret these fields.", + ) + + class AgentEnvironmentInline(BaseModel): - """Inline AgentEnvironment - a composition of environment + compute specs. + """Inline AgentEnvironment - a composition of environment, sandbox, and compute specs. Each part is a ``ref | inline | None`` union: a ``"workspace/name"`` string references a stored spec entity, an object provides the spec inline, and - ``None`` omits it. (A ``sandbox_spec`` is out of scope for now and omitted.) + ``None`` omits it. """ description: str = Field(default="", description="Human-readable description.") @@ -210,6 +226,10 @@ class AgentEnvironmentInline(BaseModel): default=None, description='"workspace/name" ref to an AgentEnvironmentSpec, an inline spec, or None.', ) + sandbox_spec: str | SandboxSpecInline | None = Field( + default=None, + description='"workspace/name" ref to an AgentSandboxSpec, an inline spec, or None.', + ) compute_spec: str | ComputeSpecInline | None = Field( default=None, description='"workspace/name" ref to an AgentComputeSpec, an inline spec, or None.', @@ -319,8 +339,16 @@ class AgentEnvironmentSpec(NemoEntity, EnvironmentSpecInline, entity_type="agent """ +class AgentSandboxSpec(NemoEntity, SandboxSpecInline, entity_type="agent_sandbox_spec"): + """A reusable sandbox spec (the isolation posture around an agent run). + + Entity type: ``agent_sandbox_spec`` + Referenced by an AgentEnvironment's ``sandbox_spec`` (by name or inline). + """ + + class AgentEnvironment(NemoEntity, AgentEnvironmentInline, entity_type="agent_environment"): - """A composition of an environment spec and a compute spec. + """A composition of an environment spec, a sandbox spec, and a compute spec. Entity type: ``agent_environment`` The single thing an AgentDeployment references. Each part is a diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py index 2de705c4e1..43ca61452b 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/schema.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/schema.py @@ -30,11 +30,13 @@ AgentEnvironment, AgentEnvironmentInline, AgentEnvironmentSpec, + AgentSandboxSpec, AgentSession, ComputeSpecInline, DeploymentMode, DeploymentStatus, EnvironmentSpecInline, + SandboxSpecInline, ) from nemo_platform_plugin.schema import NemoFilter, NemoListResponse from pydantic import BaseModel, Field @@ -142,6 +144,12 @@ class CreateComputeSpecRequest(ComputeSpecInline): name: str = Field(description="Unique compute-spec name within the workspace.") +class CreateSandboxSpecRequest(SandboxSpecInline): + """Request body for ``POST /v2/workspaces/{workspace}/sandbox-specs``.""" + + name: str = Field(description="Unique sandbox-spec name within the workspace.") + + class EnvironmentFilter(NemoFilter): """Query filter for ``GET /v2/workspaces/{workspace}/environments``.""" @@ -154,6 +162,10 @@ class ComputeSpecFilter(NemoFilter): """Query filter for ``GET /v2/workspaces/{workspace}/compute-specs``.""" +class SandboxSpecFilter(NemoFilter): + """Query filter for ``GET /v2/workspaces/{workspace}/sandbox-specs``.""" + + # --------------------------------------------------------------------------- # List response type aliases # --------------------------------------------------------------------------- @@ -164,3 +176,4 @@ class ComputeSpecFilter(NemoFilter): EnvironmentPage = NemoListResponse[AgentEnvironment] EnvironmentSpecPage = NemoListResponse[AgentEnvironmentSpec] ComputeSpecPage = NemoListResponse[AgentComputeSpec] +SandboxSpecPage = NemoListResponse[AgentSandboxSpec] diff --git a/plugins/nemo-agents/tests/unit/test_entities.py b/plugins/nemo-agents/tests/unit/test_entities.py index f9f5b343d7..47bfb5735c 100644 --- a/plugins/nemo-agents/tests/unit/test_entities.py +++ b/plugins/nemo-agents/tests/unit/test_entities.py @@ -25,9 +25,11 @@ AgentEnvironment, AgentEnvironmentInline, AgentEnvironmentSpec, + AgentSandboxSpec, AgentSession, ComputeSpecInline, EnvironmentSpecInline, + SandboxSpecInline, SessionStatus, agent_config_file_ref, ethos_file_ref, @@ -335,6 +337,7 @@ def test_entity_types(self) -> None: assert AgentEnvironment.__entity_type__ == "agent_environment" assert AgentEnvironmentSpec.__entity_type__ == "agent_environment_spec" assert AgentComputeSpec.__entity_type__ == "agent_compute_spec" + assert AgentSandboxSpec.__entity_type__ == "agent_sandbox_spec" def test_compute_spec_resources(self) -> None: cs = AgentComputeSpec( @@ -363,6 +366,7 @@ def test_environment_ref_and_inline_unions(self) -> None: by_ref = AgentEnvironment(name="env1", workspace="default", environment_spec="default/e1") assert by_ref.environment_spec == "default/e1" assert by_ref.compute_spec is None + assert by_ref.sandbox_spec is None inline = AgentEnvironment( name="env2", @@ -373,6 +377,32 @@ def test_environment_ref_and_inline_unions(self) -> None: assert isinstance(inline.environment_spec, EnvironmentSpecInline) assert isinstance(inline.compute_spec, ComputeSpecInline) + def test_sandbox_spec_fields(self) -> None: + ss = AgentSandboxSpec( + name="s1", + workspace="default", + provider="openshell", + provider_config={"policy_ref": "default/my-policy"}, + ) + assert ss.provider == "openshell" + assert ss.provider_config == {"policy_ref": "default/my-policy"} + + def test_environment_with_sandbox_spec(self) -> None: + by_ref = AgentEnvironment( + name="env3", + workspace="default", + sandbox_spec="default/s1", + ) + assert by_ref.sandbox_spec == "default/s1" + + inline = AgentEnvironment( + name="env4", + workspace="default", + sandbox_spec={"provider": "opensandbox", "provider_config": {"runtime_class": "kata"}}, + ) + assert isinstance(inline.sandbox_spec, SandboxSpecInline) + assert inline.sandbox_spec.provider == "opensandbox" + def test_data_fields_include_domain_fields(self) -> None: es = AgentEnvironmentSpec(name="e1", workspace="default", env={"FOO": "bar"}) data = es._get_data_fields() diff --git a/plugins/nemo-agents/tests/unit/test_environments_api.py b/plugins/nemo-agents/tests/unit/test_environments_api.py index ec3a649fdf..5bb453c0d8 100644 --- a/plugins/nemo-agents/tests/unit/test_environments_api.py +++ b/plugins/nemo-agents/tests/unit/test_environments_api.py @@ -17,7 +17,9 @@ AgentComputeSpec, AgentEnvironment, AgentEnvironmentSpec, + AgentSandboxSpec, EnvironmentSpecInline, + SandboxSpecInline, ) from nemo_platform_plugin.entity import NemoEntity from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError @@ -105,6 +107,51 @@ def test_delete(self) -> None: assert resp.status_code == 204 +class TestSandboxSpecRoutes: + def test_create(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=lambda e: _stamp(e)) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/sandbox-specs", + json={"name": "s1", "provider": "openshell", "provider_config": {"policy_ref": "default/p1"}}, + ) + + assert resp.status_code == 201 + created: AgentSandboxSpec = client_mock.create.call_args[0][0] + assert created.name == "s1" + assert created.provider == "openshell" + assert created.provider_config == {"policy_ref": "default/p1"} + + def test_create_conflict(self) -> None: + client_mock = AsyncMock() + client_mock.create = AsyncMock(side_effect=NemoEntityConflictError("exists")) + client = _test_client(client_mock) + + resp = client.post( + "/apis/agents/v2/workspaces/default/sandbox-specs", + json={"name": "s1", "provider": "openshell"}, + ) + assert resp.status_code == 409 + + def test_get_not_found(self) -> None: + client_mock = AsyncMock() + client_mock.get = AsyncMock(side_effect=NemoEntityNotFoundError("gone")) + client = _test_client(client_mock) + + resp = client.get("/apis/agents/v2/workspaces/default/sandbox-specs/s1") + assert resp.status_code == 404 + + def test_delete(self) -> None: + client_mock = AsyncMock() + client_mock.delete = AsyncMock(return_value=None) + client = _test_client(client_mock) + + resp = client.delete("/apis/agents/v2/workspaces/default/sandbox-specs/s1") + assert resp.status_code == 204 + + class TestEnvironmentRoutes: def test_create_with_refs(self) -> None: client_mock = AsyncMock() @@ -113,13 +160,19 @@ def test_create_with_refs(self) -> None: resp = client.post( "/apis/agents/v2/workspaces/default/environments", - json={"name": "env1", "environment_spec": "default/e1", "compute_spec": "default/c1"}, + json={ + "name": "env1", + "environment_spec": "default/e1", + "sandbox_spec": "default/s1", + "compute_spec": "default/c1", + }, ) assert resp.status_code == 201 created: AgentEnvironment = client_mock.create.call_args[0][0] assert created.name == "env1" assert created.environment_spec == "default/e1" + assert created.sandbox_spec == "default/s1" assert created.compute_spec == "default/c1" def test_create_with_inline(self) -> None: @@ -129,13 +182,19 @@ def test_create_with_inline(self) -> None: resp = client.post( "/apis/agents/v2/workspaces/default/environments", - json={"name": "env2", "environment_spec": {"env": {"A": "1"}}}, + json={ + "name": "env2", + "environment_spec": {"env": {"A": "1"}}, + "sandbox_spec": {"provider": "openshell", "provider_config": {"policy_ref": "default/p1"}}, + }, ) assert resp.status_code == 201 created: AgentEnvironment = client_mock.create.call_args[0][0] assert isinstance(created.environment_spec, EnvironmentSpecInline) assert created.environment_spec.env == {"A": "1"} + assert isinstance(created.sandbox_spec, SandboxSpecInline) + assert created.sandbox_spec.provider == "openshell" def test_get(self) -> None: env = _stamp(AgentEnvironment(name="env1", workspace="default", environment_spec="default/e1")) From bfe4f890841eb8e6c75373ae0426bdf73e844458 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 25 Aug 2026 15:19:58 -0400 Subject: [PATCH 2/5] feat(agents): wire AgentSandboxSpec through resolution, snapshot, and runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve sandbox_spec refs in environment_resolution (ResolvedEnvironment + _resolve_sandbox_spec), snapshot it onto AgentDeployment as a new sandbox field, and plumb it through the controller → RunnerBackend → DeploymentConfig.backend_config. The _SANDBOX_PROVIDER_BACKEND_CONFIG_MODELS registry maps provider names (e.g. "openshell") to backend config models; provider_config is validated against the model and compiled into DeploymentBackendConfig.{provider}. Unknown providers are silently skipped. Subprocess mode ignores sandbox (no isolation). Container mode is the only consumer today. AIRCORE-1068 Signed-off-by: Max Dubrinsky --- .../nemo_agents_plugin/api/v2/deployments.py | 1 + .../src/nemo_agents_plugin/entities.py | 8 ++++ .../environment_resolution.py | 33 ++++++++++++++- .../src/nemo_agents_plugin/runner/backend.py | 8 +++- .../nemo_agents_plugin/runner/controller.py | 1 + .../runner/deployments_backend.py | 42 +++++++++++++++++++ .../nemo_agents_plugin/runner/in_memory.py | 10 +++-- .../tests/unit/test_deployments_api.py | 20 ++++++++- .../tests/unit/test_environment_resolution.py | 27 ++++++++++-- 9 files changed, 137 insertions(+), 13 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py index 952ce51516..1f4fbf2417 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py @@ -110,6 +110,7 @@ async def create_deployment( config=merged.config, environment=body.environment, compute=resolved_environment.compute_spec, + sandbox=resolved_environment.sandbox_spec, secrets=merged.secrets, status="pending", deployment_mode=body.deployment_mode, diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py index 94bf15f8a7..499960d2a0 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/entities.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/entities.py @@ -431,6 +431,14 @@ class AgentDeployment(NemoEntity, entity_type="agent_deployment"): "vars (never plaintext) for docker/k8s modes; ignored for subprocess." ), ) + sandbox: SandboxSpecInline | None = Field( + default=None, + description=( + "Resolved sandbox spec snapshot from the referenced environment. " + "Records the isolation posture (provider + provider_config) for the deployment; " + "the runner uses it when the sandbox provider is wired." + ), + ) status: DeploymentStatus = Field( default="pending", description="Lifecycle status: pending | starting | running | failed | deleting.", diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py index 5a9491baf2..14c8af6650 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/environment_resolution.py @@ -46,8 +46,10 @@ AgentEnvironment, AgentEnvironmentInline, AgentEnvironmentSpec, + AgentSandboxSpec, ComputeSpecInline, EnvironmentSpecInline, + SandboxSpecInline, ) from nemo_platform_plugin.entities.base import parse_qualified_name from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityNotFoundError @@ -59,9 +61,10 @@ class EnvironmentResolutionError(ValueError): @dataclass(frozen=True) class ResolvedEnvironment: - """Concrete environment/compute specs resolved from an AgentEnvironment.""" + """Concrete environment/sandbox/compute specs resolved from an AgentEnvironment.""" environment_spec: EnvironmentSpecInline | None = None + sandbox_spec: SandboxSpecInline | None = None compute_spec: ComputeSpecInline | None = None @@ -101,10 +104,17 @@ async def resolve_environment( environment_spec = await _resolve_environment_spec( resolved_env.environment_spec, workspace=workspace, entity_client=entity_client ) + sandbox_spec = await _resolve_sandbox_spec( + resolved_env.sandbox_spec, workspace=workspace, entity_client=entity_client + ) compute_spec = await _resolve_compute_spec( resolved_env.compute_spec, workspace=workspace, entity_client=entity_client ) - return ResolvedEnvironment(environment_spec=environment_spec, compute_spec=compute_spec) + return ResolvedEnvironment( + environment_spec=environment_spec, + sandbox_spec=sandbox_spec, + compute_spec=compute_spec, + ) async def _resolve_agent_environment( @@ -162,6 +172,25 @@ async def _resolve_compute_spec( return spec +async def _resolve_sandbox_spec( + spec: str | SandboxSpecInline | None, + *, + workspace: str, + entity_client: NemoEntitiesClient, +) -> SandboxSpecInline | None: + if spec is None: + return None + if isinstance(spec, str): + ref_workspace, name = parse_qualified_name(spec, default_workspace=workspace) + try: + return await entity_client.get(AgentSandboxSpec, name=name, workspace=ref_workspace) + except NemoEntityNotFoundError as exc: + raise EnvironmentResolutionError( + f"AgentSandboxSpec '{name}' not found in workspace '{ref_workspace}'." + ) from exc + return spec + + def merge_environment_spec_into_agent_config( config: dict[str, Any], env_spec: EnvironmentSpecInline | None, diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py index 17ccc8c646..546bb0399e 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Literal -from nemo_agents_plugin.entities import ComputeResources, DeploymentMode, DeploymentStatus, Endpoint +from nemo_agents_plugin.entities import ComputeResources, DeploymentMode, DeploymentStatus, Endpoint, SandboxSpecInline @dataclass(frozen=True) @@ -99,6 +99,7 @@ async def create_deployment( created_by: str | None = None, resources: ComputeResources | None = None, secrets: dict[str, str] | None = None, + sandbox: SandboxSpecInline | None = None, ) -> DeploymentInfo: """Start the agent process; returns status="starting". @@ -117,6 +118,11 @@ async def create_deployment( resolved environment. Container backends compile these into secret-backed container env vars (never plaintext); the deployments-plugin substrate materializes/mounts them. Subprocess mode ignores it. + + ``sandbox`` is the resolved sandbox spec (provider + provider_config) + from the deployment's snapshotted environment. Container backends compile + it into the DeploymentConfig's backend_config for sandbox providers + (e.g. openshell). Subprocess mode ignores it. """ ... diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py index 173a61bfe7..40b1744429 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py @@ -192,6 +192,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None: created_by=dep.created_by, resources=dep.compute.resources if dep.compute is not None else None, secrets=dep.secrets or None, + sandbox=dep.sandbox, ) except Exception as exc: logger.exception("Failed to start agent for deployment '%s'", dep.name) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py index 7e7bbc9531..2f40cad537 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py @@ -22,6 +22,7 @@ from urllib.parse import urlsplit import yaml +from pydantic import BaseModel from nemo_agents_plugin.config import AgentsConfig, DeploymentsRunnerConfig from nemo_agents_plugin.entities import ( AGENT_CONFIG_FILENAME, @@ -31,6 +32,7 @@ DeploymentMode, DeploymentStatus, Endpoint, + SandboxSpecInline, ) from nemo_agents_plugin.fabric.gateway_credentials import platform_gateway_credential_env from nemo_agents_plugin.runner.backend import DeploymentInfo, ExternalLog, LogLocation, RunnerBackend @@ -46,8 +48,10 @@ ContainerPort, Deployment, DeploymentConfig, + DeploymentBackendConfig, EnvVar, HTTPGetAction, + OpenShellDeploymentConfig, Probe, ResourceRequirements, SecretRef, @@ -356,6 +360,40 @@ def _info_from_deployment(deployment: Deployment) -> DeploymentInfo: return info +_SANDBOX_PROVIDER_BACKEND_CONFIG_MODELS: dict[str, type[BaseModel]] = { + "openshell": OpenShellDeploymentConfig, +} + + +def _build_sandbox_backend_config(sandbox: SandboxSpecInline | None) -> DeploymentBackendConfig: + """Compile a sandbox spec into the DeploymentConfig's backend_config. + + Maps the sandbox ``provider`` name to the corresponding key in + ``DeploymentBackendConfig`` and validates ``provider_config`` against + the provider-specific model. Unknown providers are silently ignored + (the sandbox config is advisory; a missing backend does not block the + deployment). + """ + if sandbox is None: + return DeploymentBackendConfig() + provider = sandbox.provider + model_cls = _SANDBOX_PROVIDER_BACKEND_CONFIG_MODELS.get(provider) + if model_cls is None: + logger.debug("Sandbox provider %r has no matching backend config model; skipping.", provider) + return DeploymentBackendConfig() + try: + validated = model_cls.model_validate(sandbox.provider_config) + except Exception: + logger.warning( + "Sandbox provider_config for %r failed validation against %s; skipping.", + provider, + model_cls.__name__, + exc_info=True, + ) + return DeploymentBackendConfig() + return DeploymentBackendConfig(**{provider: validated}) + + def build_deployment_config( *, name: str, @@ -373,6 +411,7 @@ def build_deployment_config( config_files: list[ConfigFile] | None = None, resources: ComputeResources | None = None, secrets: dict[str, str] | None = None, + sandbox: SandboxSpecInline | None = None, ) -> DeploymentConfig: """Compile an agent into a long-running ``DeploymentConfig`` (Always). @@ -493,6 +532,7 @@ def build_deployment_config( "auth_proxy_sidecar": auth_proxy_identity is not None, "auth_proxy_sidecar_identity": auth_proxy_identity, "auth_proxy_sidecar_on_behalf_of": auth_proxy_on_behalf_of, + "backend_config": _build_sandbox_backend_config(sandbox), } ) @@ -523,6 +563,7 @@ async def create_deployment( created_by: str | None = None, resources: ComputeResources | None = None, secrets: dict[str, str] | None = None, + sandbox: SandboxSpecInline | None = None, ) -> DeploymentInfo: """Create DeploymentConfig + Deployment entities for the agent container.""" del port # Host port is allocated by the deployments executor, not agents. @@ -629,6 +670,7 @@ async def create_deployment( config_files=staged_config_files, resources=resources, secrets=secrets, + sandbox=sandbox, ) except ReservedSecretEnvVarError as exc: logger.error("Refusing to deploy agent %r: %s", name, exc) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py index 6de201085a..c76c4ca9eb 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py @@ -42,6 +42,7 @@ NEMO_AGENTS_SPEC_CONFIG_FORMAT, ComputeResources, DeploymentMode, + SandboxSpecInline, ) from nemo_agents_plugin.fabric.gateway_credentials import platform_gateway_credential_env from nemo_agents_plugin.runner.backend import DeploymentInfo, LocalLog, LogLocation, NotYetAvailable, RunnerBackend @@ -232,15 +233,16 @@ async def create_deployment( created_by: str | None = None, resources: ComputeResources | None = None, secrets: dict[str, str] | None = None, + sandbox: SandboxSpecInline | None = None, ) -> DeploymentInfo: """Start a local deployment for NAT workflows or Platform-owned agents.""" # created_by drives on-behalf-of delegation only for container modes (via # the auth-proxy sidecar). Subprocess deployments run in-process on the # platform host and do not use the sidecar, so it does not apply here. - # resources (compute spec) and secrets (secret-env refs) only apply to - # container modes; subprocess runs in-process on the platform host with no - # resource isolation and injects no managed secrets. - del image, deployment_mode, created_by, resources, secrets + # resources (compute spec), secrets (secret-env refs), and sandbox only + # apply to container modes; subprocess runs in-process on the platform + # host with no resource isolation and injects no managed secrets. + del image, deployment_mode, created_by, resources, secrets, sandbox if config.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: return await self._create_fabric_deployment(workspace, name, config, port, agent=agent) diff --git a/plugins/nemo-agents/tests/unit/test_deployments_api.py b/plugins/nemo-agents/tests/unit/test_deployments_api.py index eb51617857..a0af755305 100644 --- a/plugins/nemo-agents/tests/unit/test_deployments_api.py +++ b/plugins/nemo-agents/tests/unit/test_deployments_api.py @@ -20,6 +20,7 @@ AgentDeployment, AgentEnvironment, AgentEnvironmentSpec, + AgentSandboxSpec, DeploymentStatus, ) from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError @@ -122,6 +123,7 @@ def test_create_with_environment_ref_snapshots_config_and_compute(self) -> None: name="env1", workspace="default", environment_spec="default/espec", + sandbox_spec="default/sspec", compute_spec="default/cspec", ) espec = AgentEnvironmentSpec( @@ -130,11 +132,17 @@ def test_create_with_environment_ref_snapshots_config_and_compute(self) -> None: env={"CUSTOM": "from-spec"}, secrets={"APP_TOKEN": "default/app-token"}, ) + sspec = AgentSandboxSpec( + name="sspec", + workspace="default", + provider="openshell", + provider_config={"timeout": 30}, + ) cspec = AgentComputeSpec(name="cspec", workspace="default", resources={"limits": {"cpu": "2"}}) mock_entity_client = AsyncMock() - # get order: agent (route), then AgentEnvironment, env spec, compute spec (resolver). - mock_entity_client.get = AsyncMock(side_effect=[agent, environment, espec, cspec]) + # get order: agent (route), then AgentEnvironment, env spec, sandbox spec, compute spec (resolver). + mock_entity_client.get = AsyncMock(side_effect=[agent, environment, espec, sspec, cspec]) async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: deployment._id = f"deployment-{deployment.name}-id" @@ -155,6 +163,10 @@ async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: assert created.environment == "default/env1" # Environment spec env merged into the resolved config. assert created.config["environment"]["env"]["CUSTOM"] == "from-spec" + # Sandbox spec snapshotted onto the deployment. + assert created.sandbox is not None + assert created.sandbox.provider == "openshell" + assert created.sandbox.provider_config == {"timeout": 30} # Compute spec snapshotted onto the deployment. assert created.compute is not None assert created.compute.resources.limits == {"cpu": "2"} @@ -182,6 +194,7 @@ async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: "name": "fabric-dep", "environment": { "environment_spec": {"env": {"INLINE": "yes"}}, + "sandbox_spec": {"provider": "openshell", "provider_config": {"timeout": 30}}, "compute_spec": {"resources": {"requests": {"cpu": "1"}}}, }, }, @@ -190,6 +203,9 @@ async def _save_deployment(deployment: AgentDeployment) -> AgentDeployment: assert resp.status_code == 201 created: AgentDeployment = mock_entity_client.create.call_args[0][0] assert created.config["environment"]["env"]["INLINE"] == "yes" + assert created.sandbox is not None + assert created.sandbox.provider == "openshell" + assert created.sandbox.provider_config == {"timeout": 30} assert created.compute is not None assert created.compute.resources.requests == {"cpu": "1"} # Only the agent lookup hit the entity store; inline specs need no deref. diff --git a/plugins/nemo-agents/tests/unit/test_environment_resolution.py b/plugins/nemo-agents/tests/unit/test_environment_resolution.py index 35e388f764..c4565c33a9 100644 --- a/plugins/nemo-agents/tests/unit/test_environment_resolution.py +++ b/plugins/nemo-agents/tests/unit/test_environment_resolution.py @@ -15,10 +15,12 @@ AgentEnvironment, AgentEnvironmentInline, AgentEnvironmentSpec, + AgentSandboxSpec, ComputeSpecInline, EnvironmentSpecInline, McpFulfillment, ModelProviderOverride, + SandboxSpecInline, ) from nemo_agents_plugin.environment_resolution import ( EnvironmentResolutionError, @@ -50,6 +52,7 @@ def _agent_config(**overrides: Any) -> dict[str, Any]: async def test_resolve_none_returns_empty() -> None: resolved = await resolve_environment(None, workspace="default", entity_client=AsyncMock()) assert resolved.environment_spec is None + assert resolved.sandbox_spec is None assert resolved.compute_spec is None @@ -57,11 +60,15 @@ async def test_resolve_none_returns_empty() -> None: async def test_resolve_inline_environment_with_inline_specs() -> None: environment = AgentEnvironmentInline( environment_spec=EnvironmentSpecInline(env={"FOO": "bar"}), + sandbox_spec=SandboxSpecInline(provider="openshell", provider_config={"timeout": 30}), compute_spec=ComputeSpecInline(resources={"limits": {"cpu": "2"}}), ) resolved = await resolve_environment(environment, workspace="default", entity_client=AsyncMock()) assert resolved.environment_spec is not None assert resolved.environment_spec.env == {"FOO": "bar"} + assert resolved.sandbox_spec is not None + assert resolved.sandbox_spec.provider == "openshell" + assert resolved.sandbox_spec.provider_config == {"timeout": 30} assert resolved.compute_spec is not None assert resolved.compute_spec.resources.limits == {"cpu": "2"} @@ -72,22 +79,26 @@ async def test_resolve_environment_ref_dereferences_all_entities() -> None: name="env1", workspace="default", environment_spec="default/espec", + sandbox_spec="default/sspec", compute_spec="default/cspec", ) espec = AgentEnvironmentSpec(name="espec", workspace="default", env={"A": "1"}) + sspec = AgentSandboxSpec(name="sspec", workspace="default", provider="openshell") cspec = AgentComputeSpec(name="cspec", workspace="default", resources={"requests": {"cpu": "1"}}) entity_client = AsyncMock() - entity_client.get = AsyncMock(side_effect=[env_entity, espec, cspec]) + entity_client.get = AsyncMock(side_effect=[env_entity, espec, sspec, cspec]) resolved = await resolve_environment("default/env1", workspace="default", entity_client=entity_client) assert resolved.environment_spec is not None assert resolved.environment_spec.env == {"A": "1"} + assert resolved.sandbox_spec is not None + assert resolved.sandbox_spec.provider == "openshell" assert resolved.compute_spec is not None assert resolved.compute_spec.resources.requests == {"cpu": "1"} - # AgentEnvironment, then its two specs. - assert entity_client.get.await_count == 3 + # AgentEnvironment, then its three specs. + assert entity_client.get.await_count == 4 @pytest.mark.asyncio @@ -99,7 +110,7 @@ async def test_resolve_missing_environment_ref_raises() -> None: @pytest.mark.asyncio -async def test_resolve_missing_spec_ref_raises() -> None: +async def test_resolve_missing_environment_spec_ref_raises() -> None: env_entity = AgentEnvironment(name="env1", workspace="default", environment_spec="default/missing") entity_client = AsyncMock() entity_client.get = AsyncMock(side_effect=[env_entity, NemoEntityNotFoundError("gone")]) @@ -107,6 +118,14 @@ async def test_resolve_missing_spec_ref_raises() -> None: await resolve_environment("default/env1", workspace="default", entity_client=entity_client) +async def test_resolve_missing_sandbox_spec_ref_raises() -> None: + env_entity = AgentEnvironment(name="env1", workspace="default", sandbox_spec="default/missing") + entity_client = AsyncMock() + entity_client.get = AsyncMock(side_effect=[env_entity, NemoEntityNotFoundError("gone")]) + with pytest.raises(EnvironmentResolutionError, match="AgentSandboxSpec 'missing' not found"): + await resolve_environment("default/env1", workspace="default", entity_client=entity_client) + + # --------------------------------------------------------------------------- # merge_environment_spec_into_agent_config # --------------------------------------------------------------------------- From fba9b9c918c7c8883c72087b0e75611ab2553cfe Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 25 Aug 2026 15:22:42 -0400 Subject: [PATCH 3/5] feat(agents): expose --environment on deploy CLI and SDK Add --environment/-e flag to `nemo agents deploy` accepting ref strings ("workspace/name") or inline JSON. Add environment parameter to _DeploymentResource.create in the Python SDK. AIRCORE-1068 Signed-off-by: Max Dubrinsky --- .../nemo-agents/src/nemo_agents_plugin/cli.py | 34 +++++++++++++++---- .../nemo-agents/src/nemo_agents_plugin/sdk.py | 8 ++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 4be31804f4..5667bae5cc 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -949,10 +949,9 @@ def deploy( "--environment", "-e", help=( - "AgentEnvironment to deploy under, as a 'workspace/name' ref " - "(e.g. 'default/repo-research-ben'). Its EnvironmentSpec is merged " - "into the agent config and its ComputeSpec/secret refs are " - "snapshotted onto the deployment at create time." + 'AgentEnvironment for this deployment: a "workspace/name" ref string, or inline JSON ' + '(e.g. \'{"environment_spec": ..., "sandbox_spec": ..., "compute_spec": ...}\'). ' + "Resolved and snapshotted at create time." ), ), wait: bool = typer.Option( @@ -1008,8 +1007,8 @@ def deploy( payload["name"] = name if image: payload["image"] = image - if environment is not None: - payload["environment"] = environment + if environment: + payload["environment"] = _parse_environment_arg(environment) resp = _api_request("POST", base_url, f"/apis/agents/v2/workspaces/{workspace}/deployments", json_body=payload) if not wait: typer.echo(json.dumps(resp, indent=2)) @@ -1923,6 +1922,29 @@ def _resolve_timestamp_format(ctx: typer.Context) -> str | None: return None +def _parse_environment_arg(raw: str) -> str | dict[str, Any]: + """Parse ``--environment`` into a ref string or inline dict. + + If *raw* looks like a qualified name (contains '/'), treat it as an + AgentEnvironment ref. Otherwise attempt JSON decode; if that fails, + treat it as a bare environment name (single-word ref in the default + workspace). + """ + stripped = raw.strip() + if not stripped: + return None + # "workspace/name" → ref string + if "/" in stripped: + return stripped + # Try inline JSON first + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + # Fallback: bare name → ref in default workspace + return stripped + + def _api_request(method: str, base_url: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any: url = base_url.rstrip("/") + path request_kwargs: dict[str, Any] = {} diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/sdk.py b/plugins/nemo-agents/src/nemo_agents_plugin/sdk.py index f0fda5e018..68ee4c6b48 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/sdk.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/sdk.py @@ -311,11 +311,9 @@ def create( image: Container image for ``docker``/``k8s`` modes. Falls back to ``agents.deployments.default_image`` when omitted. Rejected in ``subprocess`` mode. - environment: Optional AgentEnvironment to deploy under — a - ``"workspace/name"`` ref to a stored AgentEnvironment, or an - inline environment dict. Its EnvironmentSpec is merged into the - agent config and its ComputeSpec/secret refs are snapshotted onto - the deployment at creation time. + environment: Optional AgentEnvironment: a ``"workspace/name"`` ref + string, an inline dict, or None. Resolved and snapshotted onto + the deployment at create time. workspace: Target workspace. Returns: From f64d842e0f48316a96dc2f825cf0b46e8f5c06c2 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 25 Aug 2026 15:25:27 -0400 Subject: [PATCH 4/5] chore(agents): regenerate OpenAPI spec with AgentDeployment.sandbox field AIRCORE-1068 Signed-off-by: Max Dubrinsky --- plugins/nemo-agents/openapi/openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/nemo-agents/openapi/openapi.yaml b/plugins/nemo-agents/openapi/openapi.yaml index af26a0ec7d..d07ec070e4 100644 --- a/plugins/nemo-agents/openapi/openapi.yaml +++ b/plugins/nemo-agents/openapi/openapi.yaml @@ -3992,6 +3992,12 @@ components: as ENV_VAR_NAME -> 'workspace/secret-name'. Compiled into secret-backed container env vars (never plaintext) for docker/k8s modes; ignored for subprocess. + sandbox: + allOf: + - $ref: '#/components/schemas/SandboxSpecInline' + description: Resolved sandbox spec snapshot from the referenced environment. + Records the isolation posture (provider + provider_config) for the deployment; + the runner uses it when the sandbox provider is wired. status: type: string enum: From 51dff823b9bf0568af94fcc1c6d1cd4dddee0a0c Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 25 Aug 2026 16:03:38 -0400 Subject: [PATCH 5/5] chore(sdk): sync with Stainless after AgentDeployment.sandbox OpenAPI update AIRCORE-1068 Signed-off-by: Max Dubrinsky --- sdk/python/nemo-platform/.devcontainer/Dockerfile | 3 --- sdk/python/nemo-platform/.github/workflows/ci.yml | 3 --- sdk/python/nemo-platform/.gitignore | 3 --- sdk/python/nemo-platform/.nmpcontext/openapi.yaml | 3 --- sdk/python/nemo-platform/Brewfile | 3 --- sdk/python/nemo-platform/CONTRIBUTING.md | 3 --- sdk/python/nemo-platform/SECURITY.md | 3 --- sdk/python/nemo-platform/api.md | 3 --- sdk/python/nemo-platform/bin/publish-pypi | 3 --- sdk/python/nemo-platform/pyproject.toml | 3 --- sdk/python/nemo-platform/scripts/bootstrap | 3 --- sdk/python/nemo-platform/scripts/format | 3 --- sdk/python/nemo-platform/scripts/lint | 3 --- sdk/python/nemo-platform/scripts/test | 3 --- sdk/python/nemo-platform/scripts/utils/upload-artifact.sh | 3 --- .../src/nemo_platform/resources/access_keys/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/adapters/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/auth/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/entities/api.md | 3 --- .../src/nemo_platform/resources/evaluations/api.md | 3 --- .../src/nemo_platform/resources/experiments/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/files/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/guardrail/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/iam/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/inference/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/intake/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/jobs/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/models/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/projects/api.md | 3 --- .../nemo-platform/src/nemo_platform/resources/secrets/api.md | 3 --- .../src/nemo_platform/resources/workspaces/api.md | 3 --- 31 files changed, 93 deletions(-) diff --git a/sdk/python/nemo-platform/.devcontainer/Dockerfile b/sdk/python/nemo-platform/.devcontainer/Dockerfile index b5bd8366df..62c2d13f50 100644 --- a/sdk/python/nemo-platform/.devcontainer/Dockerfile +++ b/sdk/python/nemo-platform/.devcontainer/Dockerfile @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ARG VARIANT="3.9" FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} diff --git a/sdk/python/nemo-platform/.github/workflows/ci.yml b/sdk/python/nemo-platform/.github/workflows/ci.yml index 1c7126e434..7c53993236 100644 --- a/sdk/python/nemo-platform/.github/workflows/ci.yml +++ b/sdk/python/nemo-platform/.github/workflows/ci.yml @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - name: CI on: push: diff --git a/sdk/python/nemo-platform/.gitignore b/sdk/python/nemo-platform/.gitignore index fca2b8524d..3824f4c485 100644 --- a/sdk/python/nemo-platform/.gitignore +++ b/sdk/python/nemo-platform/.gitignore @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - .prism.log .stdy.log _dev diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index cca99f1cb5..e78c2b49c2 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - openapi: 3.1.0 info: title: Nemo Platform API diff --git a/sdk/python/nemo-platform/Brewfile b/sdk/python/nemo-platform/Brewfile index b6e1351432..c43041ceff 100644 --- a/sdk/python/nemo-platform/Brewfile +++ b/sdk/python/nemo-platform/Brewfile @@ -1,5 +1,2 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - brew "uv" diff --git a/sdk/python/nemo-platform/CONTRIBUTING.md b/sdk/python/nemo-platform/CONTRIBUTING.md index 713a1d8b46..0b272dafb7 100644 --- a/sdk/python/nemo-platform/CONTRIBUTING.md +++ b/sdk/python/nemo-platform/CONTRIBUTING.md @@ -1,6 +1,3 @@ - - - ## Setting up the environment ### With `uv` diff --git a/sdk/python/nemo-platform/SECURITY.md b/sdk/python/nemo-platform/SECURITY.md index 8511ba293a..b6d43d4da2 100644 --- a/sdk/python/nemo-platform/SECURITY.md +++ b/sdk/python/nemo-platform/SECURITY.md @@ -1,6 +1,3 @@ - - - # Security Policy ## Reporting Security Issues diff --git a/sdk/python/nemo-platform/api.md b/sdk/python/nemo-platform/api.md index 951ee38a5e..c318003405 100644 --- a/sdk/python/nemo-platform/api.md +++ b/sdk/python/nemo-platform/api.md @@ -1,6 +1,3 @@ - - - # Shared Types ```python diff --git a/sdk/python/nemo-platform/bin/publish-pypi b/sdk/python/nemo-platform/bin/publish-pypi index ff7cb535f1..e72ca2fa40 100644 --- a/sdk/python/nemo-platform/bin/publish-pypi +++ b/sdk/python/nemo-platform/bin/publish-pypi @@ -1,7 +1,4 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - set -eux rm -rf dist diff --git a/sdk/python/nemo-platform/pyproject.toml b/sdk/python/nemo-platform/pyproject.toml index 558e50bcf3..e03e680f9f 100644 --- a/sdk/python/nemo-platform/pyproject.toml +++ b/sdk/python/nemo-platform/pyproject.toml @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - [project] name = "nemo-platform-sdk" description = "The official Python library for the NeMo Platform API" diff --git a/sdk/python/nemo-platform/scripts/bootstrap b/sdk/python/nemo-platform/scripts/bootstrap index 76901c1efd..5a23841b89 100755 --- a/sdk/python/nemo-platform/scripts/bootstrap +++ b/sdk/python/nemo-platform/scripts/bootstrap @@ -1,7 +1,4 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - set -e diff --git a/sdk/python/nemo-platform/scripts/format b/sdk/python/nemo-platform/scripts/format index 39a186074f..c8e1f69d25 100755 --- a/sdk/python/nemo-platform/scripts/format +++ b/sdk/python/nemo-platform/scripts/format @@ -1,7 +1,4 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - set -e diff --git a/sdk/python/nemo-platform/scripts/lint b/sdk/python/nemo-platform/scripts/lint index febee74900..96ba935a93 100755 --- a/sdk/python/nemo-platform/scripts/lint +++ b/sdk/python/nemo-platform/scripts/lint @@ -1,7 +1,4 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - set -e diff --git a/sdk/python/nemo-platform/scripts/test b/sdk/python/nemo-platform/scripts/test index 4ec9d43d10..fe50ebb134 100755 --- a/sdk/python/nemo-platform/scripts/test +++ b/sdk/python/nemo-platform/scripts/test @@ -1,7 +1,4 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - set -e diff --git a/sdk/python/nemo-platform/scripts/utils/upload-artifact.sh b/sdk/python/nemo-platform/scripts/utils/upload-artifact.sh index a8b9e0041c..d532dbb1a3 100755 --- a/sdk/python/nemo-platform/scripts/utils/upload-artifact.sh +++ b/sdk/python/nemo-platform/scripts/utils/upload-artifact.sh @@ -1,7 +1,4 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - set -exuo pipefail FILENAME=$(basename dist/*.whl) diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md index 316a673dda..e3bae96ed6 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md @@ -1,6 +1,3 @@ - - - # AccessKeys Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/adapters/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/adapters/api.md index 2430b61c9c..41f9cdfd62 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/adapters/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/adapters/api.md @@ -1,6 +1,3 @@ - - - # Adapters Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md index 0386394694..47bf46c9d7 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md @@ -1,6 +1,3 @@ - - - # Auth Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/entities/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/entities/api.md index 0fa9e84c55..79b770c00c 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/entities/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/entities/api.md @@ -1,6 +1,3 @@ - - - # Entities Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/evaluations/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/evaluations/api.md index c8df493fb4..91506a3500 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/evaluations/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/evaluations/api.md @@ -1,6 +1,3 @@ - - - # Evaluations Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/experiments/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/experiments/api.md index 5f396eb367..2185abe784 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/experiments/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/experiments/api.md @@ -1,6 +1,3 @@ - - - # Experiments Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/files/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/files/api.md index 88f7e84fac..87d92d1f43 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/files/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/files/api.md @@ -1,6 +1,3 @@ - - - # Files Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/guardrail/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/guardrail/api.md index d0868d257d..1575b59fc0 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/guardrail/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/guardrail/api.md @@ -1,6 +1,3 @@ - - - # Guardrail Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/iam/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/iam/api.md index e4d9092f98..6d3abc54d8 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/iam/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/iam/api.md @@ -1,6 +1,3 @@ - - - # Iam ## RoleBindings diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/inference/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/inference/api.md index bb75cf1782..e50ab3c913 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/inference/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/inference/api.md @@ -1,6 +1,3 @@ - - - # Inference ## VirtualModels diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md index adbe2353ea..93365b2183 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/intake/api.md @@ -1,6 +1,3 @@ - - - # Intake ## EvaluatorResults diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/jobs/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/jobs/api.md index 338353a996..350594e45f 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/jobs/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/jobs/api.md @@ -1,6 +1,3 @@ - - - # Jobs Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/models/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/models/api.md index 5a6b55bba2..227d8386bd 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/models/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/models/api.md @@ -1,6 +1,3 @@ - - - # Models Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/projects/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/projects/api.md index bbc8bfe14a..7b522f0312 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/projects/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/projects/api.md @@ -1,6 +1,3 @@ - - - # Projects Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/secrets/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/secrets/api.md index 621dea566e..f8e5574977 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/secrets/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/secrets/api.md @@ -1,6 +1,3 @@ - - - # Secrets Types: diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/workspaces/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/workspaces/api.md index c8849f75f5..bf835b3c96 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/resources/workspaces/api.md +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/workspaces/api.md @@ -1,6 +1,3 @@ - - - # Workspaces Types: