Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/changes/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ outputs:
guardrails-benchmark:
description: "'true' if the nemo-guardrails plugin or guardrails service changed"
value: ${{ steps.filter.outputs.guardrails-benchmark }}
deployments-openshell:
description: "'true' if the nemo-deployments plugin changed (OpenShell backend coverage)"
value: ${{ steps.filter.outputs.deployments-openshell }}
ngc-metadata:
description: "'true' if NGC metadata scripts, tests, or assets changed"
value: ${{ steps.filter.outputs.ngc-metadata }}
Expand Down Expand Up @@ -109,6 +112,8 @@ runs:
guardrails-benchmark:
- 'plugins/nemo-guardrails/**'
- 'services/guardrails/**'
deployments-openshell:
- 'plugins/nemo-deployments/**'
ngc-metadata:
- '.github/scripts/ngc_metadata.py'
- '.github/scripts/tests/test_ngc_metadata.py'
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jobs:
helm: ${{ steps.changes.outputs.helm }}
cpu-smoke: ${{ steps.changes.outputs.cpu-smoke }}
guardrails-benchmark: ${{ steps.changes.outputs.guardrails-benchmark }}
deployments-openshell: ${{ steps.changes.outputs.deployments-openshell }}
ngc-metadata: ${{ steps.changes.outputs.ngc-metadata }}
auth-idp: ${{ steps.changes.outputs.auth-idp }}
steps:
Expand Down Expand Up @@ -1560,6 +1561,41 @@ jobs:
retention-days: 7
path: web/packages/studio/playwright-report/

deployments-openshell-tests:
# The openshell extra is a platform-restricted manylinux_2_39 wheel that the
Comment thread
maxdubrinsky marked this conversation as resolved.
# shared unit-test job does not install, so the OpenShell backend tests skip
# there. Install the extra in this isolated, path-filtered job and exercise the
# backend for real. Kept separate so a wheel-install failure fails only this
# small job, not the whole unit suite (mirrors the nemo-guardrails extra job).
name: Deployments OpenShell backend tests
needs: [changes]
if: >
!cancelled() && (
github.event_name == 'workflow_dispatch' ||
needs.changes.outputs.deployments-openshell == 'true'
)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install uv
# No python-version pin: uv resolves the interpreter from the workspace
# requires-python (>=3.12), so this job can never fall back to an
# unsupported version.
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Run OpenShell backend unit tests
run: make test-deployments-openshell
env:
_TYPER_FORCE_DISABLE_TERMINAL: "1"

guardrails-benchmark:
# Parallel matrix jobs (one NMP per variant) so the two sweeps don't
# share mocks or contend on :8080. `guardrails-benchmark-analyze` merges
Expand Down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,14 @@ endif
@echo "Running tests for package: $(PACKAGE)..."
uv run --frozen pytest -v packages/$(PACKAGE)/tests/

.PHONY: test-deployments-openshell
test-deployments-openshell: ## Run OpenShell deployment backend unit tests with the platform-restricted [openshell] extra installed
# The openshell extra is not part of the default sync (platform-restricted
# wheel), so the shared unit-test job skips these. Install it just here and
# run the backend's tests for real (mirrors the nemo-guardrails --extra bench job).
uv run --frozen --package nemo-deployments-plugin --extra openshell \
pytest -v plugins/nemo-deployments/tests/unit/backends/openshell/

.PHONY: test-service
test-service: ## Run tests for a specific service (usage: make test-service SERVICE=evaluator)
ifndef SERVICE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ not-for:
- nemo-spec (use to write the spec file before building)
- nemo-try-agent (use to query a deployed agent)
- nemo-setup (use to install the platform first)
- deploy-sandbox (use to deploy the built agent as a governed OpenShell sandbox)
- superpowers:brainstorming (use for unrelated design work)
compatibility: nemo-platform >= 0.1.0; running platform (run nemo-setup first — uses `nemo services run`, no Docker); requires agents plugin installed; writes files to agents/; runs nemo CLI commands; defers platform-health probing to `nemo-status`; LangGraph + NAT under the hood; macOS or Linux; safe under sandbox.
maturity: active
Expand Down Expand Up @@ -70,6 +71,8 @@ Verification: confirm the deployment reached ready state.

If `DEPLOY_NOT_READY`: jump to the recovery table at the bottom.

> **Governed sandbox deployment.** To deploy this agent as a policy-governed OpenShell sandbox instead of the default executor (Landlock filesystem isolation plus default-deny network egress, so its model traffic can only reach the platform), use the `deploy-sandbox` skill once the image is built. It swaps this step's deploy path for the `openshell-local` executor and an auto-generated SandboxPolicy.
Comment thread
maxdubrinsky marked this conversation as resolved.

## Step 2: Try the agent

Invoke with one question from each category in the spec.
Expand Down Expand Up @@ -207,7 +210,7 @@ Run the success-criteria question from the spec through `nemo agents invoke` onc
## If verification fails

| Symptom | Cause | Recovery |
|---|---|---|
| --- | --- | --- |
| `agents plugin unavailable` | `plugins/nemo-agents` not installed | Re-run the install loop from `nemo-setup` Step 3 for that package only |
| `DEPLOY_NOT_READY` after wait | Container startup error or YAML rejected | Run `.venv/bin/nemo agents deployments get $AGENT_NAME`; check status detail and logs |
| YAML rejected with `extra fields` | Top-level keys beyond `functions`, `llms`, `workflow`, `intercepts`, `middleware` | Strip extras from the YAML; only those five top-level keys are valid |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
``nemo.skills`` → :func:`discover_skills` — ``() -> Path`` callable
``nemo.docs`` → :func:`discover_docs` — ``() -> Path | dict`` callable
``nemo.executors`` → :func:`discover_executors` — ``Executor`` class
``nemo.sandbox_profiles`` → :func:`discover_sandbox_profiles` — :class:`~nemo_platform_plugin.sandbox.SandboxImageProfile` instance
``nemo.inference_middleware`` → :func:`discover_inference_middleware` — :class:`~nemo_platform_plugin.inference_middleware.NemoInferenceMiddleware` subclass (typed, IGW instantiates)
``nemo.customization.contributors`` → :func:`discover_customization_contributors` — :class:`~nemo_platform_plugin.customization_contributor.CustomizationContributor` instance (typed, customization router instantiates)
``nemo.seed`` → :func:`discover_seed_jobs` — :class:`~nemo_platform_plugin.seed.NemoSeedJob` subclass (typed, platform instantiates)
Expand Down Expand Up @@ -61,6 +62,7 @@
from nemo_platform_plugin.inference_middleware import NemoInferenceMiddleware
from nemo_platform_plugin.interface import PluginManifest
from nemo_platform_plugin.job import NemoJob
from nemo_platform_plugin.sandbox import SandboxImageProfile
from nemo_platform_plugin.seed import NemoSeedJob
from nemo_platform_plugin.service import NemoService

Expand Down Expand Up @@ -480,6 +482,18 @@ def discover_executors() -> dict[str, Any]:
return discover("nemo.executors")


def discover_sandbox_profiles() -> dict[str, SandboxImageProfile]:
"""Wrapper: discover ``nemo.sandbox_profiles`` → :class:`SandboxImageProfile`.

Each entry-point value is a
:class:`~nemo_platform_plugin.sandbox.SandboxImageProfile` describing what an
image needs to run under a sandbox runtime. The entry-point name is the
runtime name (e.g. ``"openshell"``), used by ``nemo agents package
--sandbox-runtime <name>`` to resolve the right profile.
"""
return cast(dict[str, SandboxImageProfile], discover("nemo.sandbox_profiles"))


def _instantiate_customization_contributor(loaded: object) -> CustomizationContributor:
from nemo_platform_plugin.customization_contributor import CustomizationContributor

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Cross-plugin contract for sandbox-runtime image requirements.

A sandbox runtime (e.g. OpenShell) runs an agent image under a supervisor that
imposes requirements on the image: a dedicated non-root user resolved by name,
extra OS packages for its network/policy setup, and so on. Those requirements
are *provider* knowledge, but the *packager* (``nemo agents package``) is what
physically bakes them into the image.

To keep the packager provider-agnostic, a provider plugin registers one
:class:`SandboxImageProfile` under the ``nemo.sandbox_profiles`` entry-point
group; the packager discovers it by name (via
:func:`nemo_platform_plugin.discovery.discover_sandbox_profiles`) and renders
whatever it is handed. The packager never imports the provider.

These are plain, dependency-light dataclasses so a provider can describe its
profile without importing the runtime's heavy client libraries.
"""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class SandboxUser:
"""A user (and its primary group) a sandbox runtime requires in the image.

A runtime typically resolves the user by name, so the uid is not load-bearing;
``system=True`` keeps it out of the uid range a packager may reclaim for its own
default runtime user.
"""

name: str
group: str | None = None
"""Primary group name. Defaults to :attr:`name` when ``None``."""
Comment thread
maxdubrinsky marked this conversation as resolved.
system: bool = True
create_home: bool = True
home: str | None = None
"""Home directory. Defaults to ``/home/<name>`` when ``None``."""
shell: str = "/bin/bash"

def resolved_group(self) -> str:
return self.group or self.name

def resolved_home(self) -> str:
return self.home or f"/home/{self.name}"


@dataclass(frozen=True)
class SandboxImageProfile:
"""Declarative image requirements for running under a sandbox runtime.

Registered by a provider plugin under the ``nemo.sandbox_profiles``
entry-point group and consumed by ``nemo agents package
--sandbox-runtime <name>``.
"""

name: str
description: str = ""
apt_packages: tuple[str, ...] = ()
users: tuple[SandboxUser, ...] = ()
30 changes: 24 additions & 6 deletions packages/nmp_platform/config/local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ service: {}

auth:
enabled: false
allow_unsigned_jwt: true # local CLI: nemo auth login --unsigned-token
allow_unsigned_jwt: true # local CLI: nemo auth login --unsigned-token
policy_decision_point_provider: embedded
policy_decision_point_base_url: "http://localhost:8080"
# Low timeouts for fast test feedback (same as integration tests)
policy_data_refresh_interval: 2
bundle_cache_seconds: 15 # 0 = refresh on every authz call for instant permission changes
bundle_cache_seconds: 15 # 0 = refresh on every authz call for instant permission changes
admin_email: "admin@example.com"

# --- Azure AD OIDC (NeMo Platform dev deployment) ---
Expand All @@ -47,7 +47,6 @@ entities: {}

# Jobs service configuration
jobs:

# Explicitly register the subprocess executor at profile "default". This opts
# the documented `cpu/default` plugin steps (Data Designer create, Evaluator
# metrics, Anonymizer, hello-world, etc.) into the cpu→subprocess translation
Expand Down Expand Up @@ -107,9 +106,9 @@ jobs:
storage:
# Additional volume for E2E tests
additional_volume_mounts:
- volume_name: nmp-additional-volume
mount_path: /mnt/additional_storage
allow_create_volume: true
- volume_name: nmp-additional-volume
mount_path: /mnt/additional_storage
allow_create_volume: true
subprocess:
working_directory: /tmp/nmp-subprocess-jobs
cleanup_completed_jobs_immediately: false
Expand All @@ -130,6 +129,25 @@ deployments:
pull_images: false
port_range_start: 49152
port_range_end: 49251
# OpenShell-backed sandboxed agent deployments, opt-in per deployment. Each
# sandbox gets a generated default-deny SandboxPolicy.
- name: openshell-local
backend: openshell
config:
# :17670 is OpenShell's documented docker-driver default. NOT :8080, which the
# platform owns.
gateway_endpoint: http://127.0.0.1:17670
# A packaged agent image chowns /workspace to its own 'agent' user, which the
# sandbox user cannot write, so `nat serve` runs from a sandbox-writable dir.
serve_workdir: /home/sandbox
# null grants the sandbox NO direct egress: the agent reaches models through
# OpenShell's gateway-managed inference.local route and the gateway makes the
# upstream call. Set a platform_egress block for direct egress instead, or
# default_policy_path to pin a hand-written policy. Route wiring:
# plugins/nemo-deployments/examples/openshell/DEMO.ipynb.
platform_egress: null
# Docker stays the default so ordinary deployments and the e2e harness keep their
# docker/k8s path.
default_executor: local-docker

models:
Expand Down
13 changes: 13 additions & 0 deletions plugins/nemo-agents/src/nemo_agents_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ def package(
allow_root: bool = typer.Option(
False, "--allow-root", help="Disable non-root USER hardening in the rendered Dockerfile."
),
sandbox_runtime: Optional[str] = typer.Option(
None,
"--sandbox-runtime",
help=(
"Render an image compatible with a sandbox runtime (e.g. 'openshell'). "
"Discovers the runtime's image profile and bakes in its required apt "
"packages + users so the image can run inside that sandbox supervisor."
),
),
Comment thread
maxdubrinsky marked this conversation as resolved.
generate_ignore: bool = typer.Option(
True, "--ignore/--no-ignore", help="Generate a .dockerignore file alongside the Dockerfile."
),
Expand Down Expand Up @@ -419,6 +428,7 @@ def package(
format=format,
template=template,
allow_root=allow_root,
sandbox_runtime=sandbox_runtime,
agent_version=agent_version,
agent_author=agent_author,
generate_ignore=generate_ignore,
Expand All @@ -444,6 +454,7 @@ def package(
python_version=python_version,
uv_version=uv_version,
allow_root=allow_root,
sandbox_runtime=sandbox_runtime,
agent_version=agent_version,
agent_author=agent_author,
template_path=template,
Expand Down Expand Up @@ -556,6 +567,7 @@ def _package_render_only(
format: str,
template: Optional[str],
allow_root: bool,
sandbox_runtime: Optional[str],
agent_version: Optional[str],
agent_author: Optional[str],
generate_ignore: bool,
Expand All @@ -582,6 +594,7 @@ def _package_render_only(
nat_version=nat_version,
uv_version=uv_version,
allow_root=allow_root,
sandbox_runtime=sandbox_runtime,
agent_version=agent_version,
agent_author=agent_author,
template_path=template,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def build_agent_image(
python_version: str | None = None,
uv_version: str | None = None,
allow_root: bool = False,
sandbox_runtime: str | None = None,
agent_version: str | None = None,
agent_author: str | None = None,
template_path: str | None = None,
Expand Down Expand Up @@ -192,6 +193,7 @@ def build_agent_image(
nat_version=nat_version,
uv_version=uv_version,
allow_root=allow_root,
sandbox_runtime=sandbox_runtime,
agent_version=agent_version,
agent_author=agent_author,
template_path=template_path,
Expand Down
Loading
Loading