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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ Before doing anything that requires a running NeMo platform (`nemo services`, `n

When working with the NeMo CLI (`nemo`), always check available skills first before exploring `--help`. Skills contain exact command syntax, JSON structures, and working examples that are much faster than trial-and-error discovery.

## Building a Studio plugin web UI

A plugin can ship a web UI that Studio loads at runtime and renders **inside its own React tree** — sharing Studio's React, router, and KUI design system rather than bundling its own. Before writing or reviewing plugin web code, read [plugins/example-plugin/web/AGENTS.md](plugins/example-plugin/web/AGENTS.md): it holds the contract (a `Root` component + `navItems`, externalized shared deps, KUI + theme tokens, auth rules) and is the canonical template to copy.

## Writing Python Code

- Don't put `__init__.py` files in packages. Instead prefer implicit namespace packages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path


@dataclass
Expand All @@ -26,3 +27,23 @@ class PluginManifest:
name: str
version: str
description: str = field(default="")


@dataclass
class StudioSpec:
"""Describes a plugin's Studio web UI contribution.

Registered under the ``nemo.studio`` entry-point group as a zero-argument
callable that returns an instance of this class.

Attributes:
name: Entry-point key matching the plugin name, e.g. ``"example"``.
bundle_path: Absolute path to the plugin's built ``web/dist/index.js``
on disk. Use ``Path(__file__).parent... / "web" / "dist" / "index.js"``
so the path resolves correctly for both editable and wheel installs.
``None`` for plugins that have no web UI (Python-only plugins that
still wish to appear in the ``/apis/plugins`` manifest).
"""

name: str
bundle_path: Path | None = field(default=None)
24 changes: 24 additions & 0 deletions packages/nemo_platform_plugin/tests/test_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Unit tests for nemo_platform_plugin.interface."""

from __future__ import annotations

import dataclasses
from pathlib import Path

from nemo_platform_plugin.interface import StudioSpec


def test_studio_spec_stores_name_and_bundle_path():
path = Path("/some/web/dist/index.js")
spec = StudioSpec(name="example", bundle_path=path)
assert spec.name == "example"
assert spec.bundle_path == path


def test_studio_spec_is_dataclass():
"""StudioSpec should be a plain dataclass — no pydantic, no validation."""
assert dataclasses.is_dataclass(StudioSpec)
assert len(dataclasses.fields(StudioSpec)) == 2
2 changes: 2 additions & 0 deletions packages/nmp_common/src/nmp/common/auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ def _embedded_pdp_base_url_hint(config: AuthConfig) -> str:
# GET requests to these paths bypass authentication (e.g. / -> /studio redirect).
PUBLIC_GET_PATHS = {
"/",
"/apis/plugins", # Studio plugin manifest — fetched by the SPA before login completes
}

# Path prefixes that bypass authorization
BYPASS_PREFIXES = (
"/apis/auth/authenticate/", # Envoy ext_authz path_prefix callout includes the original protected path
"/studio", # Studio UI static files — the SPA handles its own OIDC login
"/plugin-ui/", # Studio plugin bundles — loaded via dynamic import(), cannot send Authorization
)


Expand Down
58 changes: 57 additions & 1 deletion packages/nmp_common/tests/auth/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from nmp.common.auth.client import AuthClient
from nmp.common.auth.dependencies import get_auth_client
from nmp.common.auth.jwt import TokenClaims, UnsignedJWTRejectedError
from nmp.common.auth.middleware import HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, AuthorizationMiddleware
from nmp.common.auth.middleware import BYPASS_PREFIXES, HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, AuthorizationMiddleware
from nmp.common.auth.models import Principal
from nmp.common.auth.token_resolver import ResolvedBearerToken
from nmp.common.config import AuthConfig, Configuration
Expand Down Expand Up @@ -161,6 +161,62 @@ async def root_handler():
mock_authorize.assert_not_called()


class TestStudioPluginBypass:
"""Studio plugin manifest and bundles are public — the SPA fetches the manifest
anonymously and loads bundles via dynamic import(), which cannot send Authorization."""

def test_plugin_paths_in_bypass_lists(self):
assert "/apis/plugins" in PUBLIC_GET_PATHS
assert "/plugin-ui/" in BYPASS_PREFIXES

def test_plugins_manifest_get_bypasses_auth(self, auth_config_enabled):
app = FastAPI()

@app.get("/apis/plugins")
async def list_plugins():
return []

Configuration.set_override(auth_config_enabled)
app.add_middleware(AuthorizationMiddleware, service_name="test-service")

client = TestClient(app)
with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize:
mock_authorize.return_value = MagicMock(allowed=False)
response = client.get("/apis/plugins")

assert response.status_code == 200
mock_authorize.assert_not_called()

def test_plugin_bundle_get_bypasses_auth(self, auth_config_enabled):
app = FastAPI()

@app.get("/plugin-ui/{plugin_name}/{filename}")
async def serve_bundle(plugin_name: str, filename: str):
return {"plugin": plugin_name, "filename": filename}

Configuration.set_override(auth_config_enabled)
app.add_middleware(AuthorizationMiddleware, service_name="test-service")

client = TestClient(app)
with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize:
mock_authorize.return_value = MagicMock(allowed=False)
response = client.get("/plugin-ui/example/index.js")

assert response.status_code == 200
mock_authorize.assert_not_called()

def test_other_paths_still_require_auth(self, auth_config_enabled):
"""The plugin bypasses must not open up other routes."""
app = create_test_app(auth_config_enabled)
client = TestClient(app, raise_server_exceptions=False)

with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize:
mock_authorize.return_value = MagicMock(allowed=False)
response = client.get("/test")

assert response.status_code == 401


class TestBearerTokenAuth:
"""Tests for Bearer token authentication in middleware."""

Expand Down
3 changes: 3 additions & 0 deletions plugins/example-plugin/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ example = "nemo_example_plugin.skills:get_skills_path"
[project.entry-points."nemo.inference_middleware"]
"nemo-example-middleware" = "nemo_example_plugin.middleware:ExampleInferenceMiddleware"

[project.entry-points."nemo.studio"]
example = "nemo_example_plugin.studio:get_studio_spec"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down
20 changes: 20 additions & 0 deletions plugins/example-plugin/src/nemo_example_plugin/studio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Studio web UI registration for the example plugin."""

from __future__ import annotations

from pathlib import Path

from nemo_platform_plugin.interface import StudioSpec


def get_studio_spec() -> StudioSpec:
"""Return the StudioSpec for the example plugin's web UI.

Uses ``__file__`` so the path resolves correctly for both editable
(``uv pip install -e``) and wheel installs.
"""
bundle_path = Path(__file__).parent / "web" / "dist" / "index.js"
return StudioSpec(name="example", bundle_path=bundle_path)
Loading
Loading