diff --git a/AGENTS.md b/AGENTS.md index b52ecc2571..018207a705 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py index 8e35682316..abfbd198fb 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py @@ -6,6 +6,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path @dataclass @@ -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) diff --git a/packages/nemo_platform_plugin/tests/test_interface.py b/packages/nemo_platform_plugin/tests/test_interface.py new file mode 100644 index 0000000000..f1b593c9ad --- /dev/null +++ b/packages/nemo_platform_plugin/tests/test_interface.py @@ -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 diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py index f8b63a13a1..58a688a1f9 100644 --- a/packages/nmp_common/src/nmp/common/auth/middleware.py +++ b/packages/nmp_common/src/nmp/common/auth/middleware.py @@ -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 ) diff --git a/packages/nmp_common/tests/auth/test_middleware.py b/packages/nmp_common/tests/auth/test_middleware.py index bd62f85146..6a83f4cc81 100644 --- a/packages/nmp_common/tests/auth/test_middleware.py +++ b/packages/nmp_common/tests/auth/test_middleware.py @@ -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 @@ -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.""" diff --git a/plugins/example-plugin/pyproject.toml b/plugins/example-plugin/pyproject.toml index d6533b3655..509f7d515a 100644 --- a/plugins/example-plugin/pyproject.toml +++ b/plugins/example-plugin/pyproject.toml @@ -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" diff --git a/plugins/example-plugin/src/nemo_example_plugin/studio.py b/plugins/example-plugin/src/nemo_example_plugin/studio.py new file mode 100644 index 0000000000..9324753d60 --- /dev/null +++ b/plugins/example-plugin/src/nemo_example_plugin/studio.py @@ -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) diff --git a/plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js b/plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js new file mode 100644 index 0000000000..f04c97198d --- /dev/null +++ b/plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Button as e, Flex as t, Stack as n, Text as r } from "@nvidia/foundations-react-core"; +import { NavLink as i, Navigate as a, Outlet as o, Route as s, Routes as c } from "react-router-dom"; +import { jsx as l, jsxs as u } from "react/jsx-runtime"; +//#region src/Root.tsx +function d({ host: e }) { + return /* @__PURE__ */ l(c, { children: /* @__PURE__ */ u(s, { + element: /* @__PURE__ */ l(f, {}), + children: [ + /* @__PURE__ */ l(s, { + index: !0, + element: /* @__PURE__ */ l(a, { + to: "overview", + replace: !0 + }) + }), + /* @__PURE__ */ l(s, { + path: "overview", + element: /* @__PURE__ */ l(m, { host: e }) + }), + /* @__PURE__ */ l(s, { + path: "auth", + element: /* @__PURE__ */ l(h, { getAccessToken: e.auth.getAccessToken }) + }), + /* @__PURE__ */ l(s, { + path: "workspace", + element: /* @__PURE__ */ l(g, { workspaceId: e.workspaceId }) + }), + /* @__PURE__ */ l(s, { + path: "*", + element: /* @__PURE__ */ l(_, {}) + }) + ] + }) }); +} +function f() { + let e = ({ isActive: e }) => `px-3 py-1 rounded text-sm font-medium transition-colors ${e ? "text-primary bg-surface-hover" : "text-subtle hover:text-primary"}`; + return /* @__PURE__ */ u(n, { + gap: "4", + className: "h-full p-4", + children: [/* @__PURE__ */ u(t, { + gap: "2", + className: "border-b border-subtle pb-2", + children: [ + /* @__PURE__ */ l(i, { + to: "overview", + className: e, + children: "Overview" + }), + /* @__PURE__ */ l(i, { + to: "auth", + className: e, + children: "Auth" + }), + /* @__PURE__ */ l(i, { + to: "workspace", + className: e, + children: "Workspace" + }) + ] + }), /* @__PURE__ */ l("div", { + className: "flex-1", + children: /* @__PURE__ */ l(o, {}) + })] + }); +} +function p({ children: e }) { + return /* @__PURE__ */ l("pre", { + className: "bg-surface-sunken text-subtle rounded p-3 text-xs overflow-x-auto font-mono", + children: e + }); +} +function m({ host: i }) { + let { data: a, isPending: o, isError: s } = i.sdk.platform.useEntitiesListWorkspaces({ + page: 1, + page_size: 100 + }, { query: { staleTime: 5e3 } }), c = a?.data ?? []; + return /* @__PURE__ */ u(n, { + gap: "2", + children: [ + /* @__PURE__ */ l(r, { + kind: "label/bold/lg", + children: "Example Plugin" + }), + /* @__PURE__ */ l(r, { + kind: "body/regular/sm", + color: "secondary", + children: "This is an example Studio plugin. Use the tabs above or the Studio side nav to explore what information is available to a plugin at runtime." + }), + /* @__PURE__ */ u(n, { + gap: "1", + children: [ + /* @__PURE__ */ l(r, { + kind: "label/bold/sm", + children: "Shared SDK" + }), + /* @__PURE__ */ l(r, { + kind: "body/regular/xs", + color: "secondary", + children: "Listed via Studio's sdk.platform.useEntitiesListWorkspaces() — the platform's typed hook, running on Studio's authenticated axios and shared QueryClient rather than a plugin copy." + }), + o ? /* @__PURE__ */ l(r, { + kind: "body/regular/xs", + color: "secondary", + children: "Loading…" + }) : s ? /* @__PURE__ */ l(r, { + kind: "body/regular/xs", + color: "danger", + children: "Request failed." + }) : /* @__PURE__ */ u(r, { + kind: "body/regular/sm", + children: [ + c.length, + " workspaces: ", + c.map((e) => e.name).join(", ") + ] + }) + ] + }), + /* @__PURE__ */ u(n, { + gap: "1", + children: [ + /* @__PURE__ */ l(r, { + kind: "label/bold/sm", + children: "Host capabilities" + }), + /* @__PURE__ */ l(r, { + kind: "body/regular/xs", + color: "secondary", + children: "Studio's notifications, telemetry, and navigation, all off the host handle — no plugin-side setup." + }), + /* @__PURE__ */ u(t, { + gap: "2", + children: [/* @__PURE__ */ l(e, { + kind: "secondary", + onClick: () => { + i.notifications.notify("Toast from the example plugin", "success"), i.telemetry.event("overview_notify_clicked"); + }, + children: "Notify" + }), /* @__PURE__ */ l(e, { + kind: "secondary", + onClick: () => i.navigation.navigate(`/workspaces/${i.workspaceId}/base-models`), + children: "Go to Base Models" + })] + }) + ] + }) + ] + }); +} +function h({ getAccessToken: e }) { + let t = e(), i = null; + try { + let e = t.split(".")[1]; + e && (i = JSON.parse(atob(e.replace(/-/g, "+").replace(/_/g, "/")))); + } catch {} + return /* @__PURE__ */ u(n, { + gap: "3", + children: [ + /* @__PURE__ */ l(r, { + kind: "label/bold/md", + children: "Auth" + }), + /* @__PURE__ */ l(r, { + kind: "body/regular/sm", + color: "secondary", + children: "Studio passes an OIDC access token to every plugin via the plugin's auth prop. Call getAccessToken() per request — it returns the current token after silent renewal — and use it as a Bearer token." + }), + /* @__PURE__ */ u(n, { + gap: "1", + children: [/* @__PURE__ */ l(r, { + kind: "label/bold/sm", + children: "Example API call" + }), /* @__PURE__ */ l(p, { children: "fetch('/apis/v1/workspaces', {\n headers: { Authorization: `Bearer ${getAccessToken()}` },\n})" })] + }), + /* @__PURE__ */ u(n, { + gap: "1", + children: [/* @__PURE__ */ l(r, { + kind: "label/bold/sm", + children: "Token claims (decoded, not verified)" + }), i ? /* @__PURE__ */ l(p, { children: JSON.stringify(i, null, 2) }) : /* @__PURE__ */ l(r, { + kind: "body/regular/xs", + color: "secondary", + children: t ? "Could not decode token." : "No token provided." + })] + }) + ] + }); +} +function g({ workspaceId: e }) { + return /* @__PURE__ */ u(n, { + gap: "3", + children: [ + /* @__PURE__ */ l(r, { + kind: "label/bold/md", + children: "Workspace" + }), + /* @__PURE__ */ l(r, { + kind: "body/regular/sm", + color: "secondary", + children: "Studio passes the current workspace ID to every plugin via the plugin's workspaceId prop." + }), + /* @__PURE__ */ u(n, { + gap: "1", + children: [/* @__PURE__ */ l(r, { + kind: "label/bold/sm", + children: "Current workspace" + }), /* @__PURE__ */ l(p, { children: e })] + }), + /* @__PURE__ */ u(n, { + gap: "1", + children: [/* @__PURE__ */ l(r, { + kind: "label/bold/sm", + children: "Example API call scoped to this workspace" + }), /* @__PURE__ */ l(p, { children: "fetch(`/apis/v1/workspaces/${workspaceId}/models`, {\n headers: { Authorization: `Bearer ${getAccessToken()}` },\n})" })] + }) + ] + }); +} +function _() { + return /* @__PURE__ */ l(r, { + kind: "body/regular/sm", + color: "secondary", + children: "Page not found." + }); +} +//#endregion +//#region src/Nav.tsx +var v = (e) => [{ + group: "Example Plugin", + items: [ + { + id: "example-overview", + iconName: "flask-conical", + label: "Overview", + href: `/workspaces/${e}/plugin/example/overview` + }, + { + id: "example-auth", + iconName: "key-round", + label: "Auth", + href: `/workspaces/${e}/plugin/example/auth` + }, + { + id: "example-workspace", + iconName: "building-2", + label: "Workspace", + href: `/workspaces/${e}/plugin/example/workspace` + } + ] +}]; +//#endregion +export { d as Root, v as navItems }; diff --git a/plugins/example-plugin/tests/test_studio.py b/plugins/example-plugin/tests/test_studio.py new file mode 100644 index 0000000000..7921fdd760 --- /dev/null +++ b/plugins/example-plugin/tests/test_studio.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for nemo_example_plugin.studio.""" + +from __future__ import annotations + +from nemo_example_plugin.studio import get_studio_spec +from nemo_platform_plugin.interface import StudioSpec + + +def test_get_studio_spec_returns_studio_spec(): + spec = get_studio_spec() + assert isinstance(spec, StudioSpec) + assert spec.name == "example" + assert spec.bundle_path is not None + assert spec.bundle_path.name == "index.js" + assert "web" in spec.bundle_path.parts + assert "dist" in spec.bundle_path.parts diff --git a/plugins/example-plugin/web/.gitignore b/plugins/example-plugin/web/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/plugins/example-plugin/web/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/plugins/example-plugin/web/AGENTS.md b/plugins/example-plugin/web/AGENTS.md new file mode 100644 index 0000000000..435df8061a --- /dev/null +++ b/plugins/example-plugin/web/AGENTS.md @@ -0,0 +1,104 @@ +# Studio plugin web UI — agent instructions + +This is the web UI for a NeMo Studio plugin. Studio loads the built bundle at +runtime and renders it **inside its own React tree**. This dir is also the +canonical template other plugins copy — keep it exemplary. + +Runtime contract: `../../../web/packages/studio/src/plugins/types.ts`. + +## Mental model + +- **One root, not two.** Export a `Root` **component**; Studio renders `` + under its own Router / QueryClient / KaizenThemeProvider. Never call + `createRoot`, never create a `BrowserRouter`. +- **Share the singletons that carry context** — React, react-dom, react-router, + and `@nvidia/foundations-react-core` resolve via Studio's import map. This dir + **externalizes** them so the plugin uses Studio's one instance (shared router + + theme). Everything else bundles privately. + +## Rules — DO / DON'T + +| Concern | DO | DON'T | +| --- | --- | --- | +| Entry | `export { Root }` (component) + `export { navItems }` from `src/index.ts` | export `mount()` or call `createRoot` | +| Routing | Studio's shared router — `Routes`/`Route`/`NavLink`/`Navigate`/`Outlet`/`useNavigate`, paths relative to the plugin mount | `BrowserRouter`, `history.pushState` patching, hardcoded `basename` | +| Components | KUI from `@nvidia/foundations-react-core` — `Text`, `Stack`, `Flex`, `Button` | hand-rolled styled `
`s or native ` + + + + + ); +} + +function AuthPage({ getAccessToken }: { getAccessToken: () => string }) { + const accessToken = getAccessToken(); + // Parse the JWT payload (without verification — for display only). + let claims: Record | null = null; + try { + const payload = accessToken.split('.')[1]; + if (payload) { + claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) as Record; + } + } catch { + // malformed token — show raw + } + + return ( + + Auth + + Studio passes an OIDC access token to every plugin via the plugin's + auth prop. Call getAccessToken() per request — it returns the current + token after silent renewal — and use it as a Bearer token. + + + + Example API call + {`fetch('/apis/v1/workspaces', { + headers: { Authorization: \`Bearer \${getAccessToken()}\` }, +})`} + + + + Token claims (decoded, not verified) + {claims ? ( + {JSON.stringify(claims, null, 2)} + ) : ( + + {accessToken ? 'Could not decode token.' : 'No token provided.'} + + )} + + + ); +} + +function WorkspacePage({ workspaceId }: { workspaceId: string }) { + return ( + + Workspace + + Studio passes the current workspace ID to every plugin via the plugin's + workspaceId prop. + + + + Current workspace + {workspaceId} + + + + Example API call scoped to this workspace + {`fetch(\`/apis/v1/workspaces/\${workspaceId}/models\`, { + headers: { Authorization: \`Bearer \${getAccessToken()}\` }, +})`} + + + ); +} + +function NotFound() { + return ( + + Page not found. + + ); +} diff --git a/plugins/example-plugin/web/src/index.ts b/plugins/example-plugin/web/src/index.ts new file mode 100644 index 0000000000..49eece09b3 --- /dev/null +++ b/plugins/example-plugin/web/src/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { Root } from './Root'; +export { navItems } from './Nav'; diff --git a/plugins/example-plugin/web/src/types.ts b/plugins/example-plugin/web/src/types.ts new file mode 100644 index 0000000000..be7f1b5cc6 --- /dev/null +++ b/plugins/example-plugin/web/src/types.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// These types are the plugin contract and must stay in sync with +// web/packages/studio/src/plugins/types.ts in the Studio monorepo. +// They are intentionally duplicated here so the example plugin has no +// build-time dependency on Studio's internal packages. + +// Minimal mirror of Studio's PluginSdk — only the hooks this example calls, so it +// stays free of the private @nemo/sdk package. +export interface PluginSdk { + platform: { + useEntitiesListWorkspaces: ( + params?: { page?: number; page_size?: number }, + options?: { query?: { enabled?: boolean; staleTime?: number } } + ) => { + data?: { data?: Array<{ name: string }> }; + isPending: boolean; + isError: boolean; + }; + }; +} + +export interface PluginNavigation { + navigate: (to: string) => void; + back: () => void; +} + +export type NotificationType = 'success' | 'error' | 'info' | 'warning'; + +export interface PluginNotifications { + notify: (message: string, type?: NotificationType) => void; +} + +export interface PluginTelemetry { + info: (message: string, cause?: unknown) => void; + warn: (message: string, cause?: unknown) => void; + error: (message: string, cause?: unknown) => void; + event: (name: string, attributes?: Record) => void; +} + +export interface PluginHost { + workspaceId: string; + auth: { + accessToken: string; + getAccessToken: () => string; + }; + sdk: PluginSdk; + navigation: PluginNavigation; + notifications: PluginNotifications; + telemetry: PluginTelemetry; +} + +export interface PluginRootProps { + host: PluginHost; +} + +export interface PluginNavItem { + id: string; + iconName: string; + label: string; + href: string; +} + +export interface PluginNavGroup { + group: string; + items: PluginNavItem[]; +} diff --git a/plugins/example-plugin/web/tsconfig.json b/plugins/example-plugin/web/tsconfig.json new file mode 100644 index 0000000000..42e0521690 --- /dev/null +++ b/plugins/example-plugin/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/plugins/example-plugin/web/vite.config.ts b/plugins/example-plugin/web/vite.config.ts new file mode 100644 index 0000000000..a345ef9d50 --- /dev/null +++ b/plugins/example-plugin/web/vite.config.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// Keep in sync with VENDOR_EXTERNALS in web/packages/studio/vite.config.ts — +// Studio serves a single shared React/react-dom/router instance via an +// import map, and the plugin bundle must leave these as bare specifiers so +// the browser resolves them to that shared instance at runtime. +const STUDIO_SHARED_DEPS = [ + 'react', + 'react/jsx-runtime', + 'react-dom', + 'react-dom/client', + 'react-router', + 'react-router-dom', + // Studio's design system, shared via the import map so the plugin's KUI + // components use Studio's single foundations instance and theme. + '@nvidia/foundations-react-core', + // Shared so the plugin's useQuery uses Studio's QueryClient (one cache). + '@tanstack/react-query', +]; + +// Prepended to the built bundle so the emitted artifact keeps an SPDX header — +// minification strips source comments, and CI's copyright-header check +// (script/copyright_fixer.py) requires the header literally in the file. +const LICENSE_BANNER = + '// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\n' + + '// SPDX-License-Identifier: Apache-2.0'; + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: () => 'index.js', + }, + // Build into the Python package so it gets included in wheel installs + outDir: '../src/nemo_example_plugin/web/dist', + emptyOutDir: true, + rolldownOptions: { + external: STUDIO_SHARED_DEPS, + output: { + banner: LICENSE_BANNER, + }, + }, + }, +}); + diff --git a/services/studio/src/nmp/studio/config.py b/services/studio/src/nmp/studio/config.py index 7270a935ae..dd7fbefdbc 100644 --- a/services/studio/src/nmp/studio/config.py +++ b/services/studio/src/nmp/studio/config.py @@ -47,7 +47,7 @@ def is_origin_allowed(self, origin: str, same_origin: str | None = None) -> bool return any(fnmatchcase(origin, allowed_origin) for allowed_origin in self.allowed_origins) -class StudioConfig(create_service_config_class("studio")): # type: ignore[misc] +class StudioConfig(create_service_config_class("studio")): # type: ignore[misc] # ty: ignore[unsupported-base] """Configuration for the Studio service. This configuration is loaded from the 'studio' section of the @@ -164,7 +164,7 @@ def env_replacements(self) -> dict[str, str]: if value is not None: replacements[mapping.marker] = value logger.debug(f"Resolved {mapping.marker} -> {value}") - elif mapping.default: + elif mapping.default is not None: replacements[mapping.marker] = mapping.default logger.debug(f"Using default for {mapping.marker} -> {mapping.default}") else: diff --git a/services/studio/src/nmp/studio/env_mappings.py b/services/studio/src/nmp/studio/env_mappings.py index d1b43a30ef..f97a80cc2c 100644 --- a/services/studio/src/nmp/studio/env_mappings.py +++ b/services/studio/src/nmp/studio/env_mappings.py @@ -148,6 +148,11 @@ class EnvMapping: config_path="studio.feature_flags.optimizer_enabled", default="true", ), + EnvMapping( + marker="STUDIO_UI_VITE_FF_PLUGINS_ENABLED", + config_path="studio.feature_flags.plugins_enabled", + default="true", + ), EnvMapping( marker="STUDIO_UI_VITE_FF_SAFE_SYNTHESIZER_ENABLED", config_path="studio.feature_flags.safe_synthesizer_enabled", diff --git a/services/studio/src/nmp/studio/plugins.py b/services/studio/src/nmp/studio/plugins.py new file mode 100644 index 0000000000..bc899e8b9b --- /dev/null +++ b/services/studio/src/nmp/studio/plugins.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Plugin discovery and API router for Studio web UI plugins.""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from functools import cache +from importlib.metadata import Distribution +from pathlib import Path +from urllib.parse import unquote, urlparse + +from fastapi import APIRouter +from nemo_platform_plugin.discovery import discover_entry_points, discover_manifests, discover_studio +from nemo_platform_plugin.interface import StudioSpec +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +# Plugin names are constrained to the same character set enforced by the +# frontend security gate: lowercase letter, followed by lowercase letters, +# digits, or hyphens. This prevents path traversal or URL injection via a +# malicious/buggy plugin's StudioSpec.name value. +_PLUGIN_NAME_RE = re.compile(r"^[a-z][a-z0-9-]+$") + + +def _editable_source_root(dist: Distribution) -> Path | None: + """Source directory of an editable install, per PEP 610's direct_url.json. + + For editable installs the dist-info sits in site-packages but the real + package files live in a source tree elsewhere — ``dist.locate_file('.')`` + returns site-packages, so it's not a usable root for bundle validation. + """ + try: + raw = dist.read_text("direct_url.json") + if not raw or not isinstance(raw, str): + return None + data = json.loads(raw) + if not data.get("dir_info", {}).get("editable"): + return None + url = data.get("url", "") + parsed = urlparse(url) + if parsed.scheme != "file" or not parsed.path: + return None + return Path(unquote(parsed.path)).resolve() + except Exception: + return None + + +def _validate_bundle_path(ep_name: str, bundle_path: Path) -> bool: + """Return True if *bundle_path* is safe to serve as a plugin bundle. + + Three checks are applied: + + 1. The filename must be ``index.js`` — the advertised ``bundleUrl`` is + always ``/plugin-ui//index.js``, so any other filename would + validate here yet 404 in the browser. + 2. The resolved path must be a regular file (prevents accidentally serving + system directories, e.g. when ``bundle_path`` points to ``/etc/passwd`` + its parent ``/etc`` would otherwise be exposed). + 3. Best-effort: the resolved path must be within the plugin's distribution + root as reported by ``importlib.metadata``, or — for PEP 660 editable + installs — within the source directory recorded in ``direct_url.json``. + Skipped when distribution metadata is unavailable (e.g. in tests). + """ + if bundle_path.name != "index.js": + logger.warning( + "Studio plugin %r bundle_path %r must be named index.js — skipping bundle", + ep_name, + bundle_path, + ) + return False + + resolved = bundle_path.resolve() + if not resolved.is_file(): + logger.warning( + "Studio plugin %r bundle_path %r does not point to a regular file — skipping bundle", + ep_name, + bundle_path, + ) + return False + + ep = discover_entry_points("nemo.studio").get(ep_name) + dist = getattr(ep, "dist", None) if ep is not None else None + if dist is not None: + try: + roots: list[Path] = [Path(dist.locate_file(".")).resolve()] + editable_root = _editable_source_root(dist) + if editable_root is not None: + roots.append(editable_root) + if not any(resolved.is_relative_to(root) for root in roots): + logger.warning( + "Studio plugin %r bundle_path %r is outside distribution root(s) %s — skipping bundle", + ep_name, + bundle_path, + [str(r) for r in roots], + ) + return False + except Exception: + logger.debug("Could not determine distribution root for plugin %r — skipping path check", ep_name) + + return True + + +@dataclass +class PluginManifestResponse: + """Manifest returned by the /apis/plugins endpoint. + + Attributes: + name: Plugin entry-point key, e.g. ``"example"``. + bundle_url: URL served by the platform, e.g. ``"/plugin-ui/example/index.js"``. + ``None`` for plugins that registered without a web bundle. + bundle_dir: Parent directory of the plugin's built ``index.js``, for static + file mounting. ``None`` when there is no bundle or the directory could + not be resolved. + """ + + name: str + bundle_url: str | None = field(default=None) + bundle_dir: Path | None = field(default=None) + + +class _PluginManifestOut(BaseModel): + name: str + bundleUrl: str | None = None + + +@cache +def discover_plugins() -> list[PluginManifestResponse]: + """Discover all installed NMP plugins and return their Studio manifests. + + Every plugin found by ``discover_manifests()`` appears in the response. + Plugins that also register a ``nemo.studio`` entry point get a + ``bundleUrl`` pointing to their static JS bundle; all others get + ``bundleUrl: null``. + + ``bundleUrl`` is always derived from the entry-point key — never from + ``spec.name`` or ``spec.bundle_path`` — to prevent bundle-slot hijacking + and path-traversal attacks. + + The result is cached so factories are called exactly once per process. + """ + all_plugin_names = set(discover_manifests()) + + # Studio-specific specs, keyed by entry-point name. + studio_factories = discover_studio() + studio_specs: dict[str, StudioSpec] = {} + for name, factory in studio_factories.items(): + try: + spec = factory() + if not isinstance(spec, StudioSpec): + logger.warning( + "Studio plugin %r returned %r instead of StudioSpec — skipping bundle", + name, + type(spec).__name__, + ) + continue + if spec.name != name: + logger.warning( + "Studio plugin %r returned spec.name=%r — must match the entry-point key; skipping bundle", + name, + spec.name, + ) + continue + if not _PLUGIN_NAME_RE.match(name): + logger.warning( + "Studio plugin %r has invalid entry-point key (must match [a-z][a-z0-9-]+) — skipping bundle", + name, + ) + continue + studio_specs[name] = spec + except Exception: + logger.warning("Failed to load studio spec for %r — no bundle will be served", name, exc_info=True) + + manifests: list[PluginManifestResponse] = [] + for name in sorted(all_plugin_names): + spec = studio_specs.get(name) + if spec is not None and spec.bundle_path is not None and _validate_bundle_path(name, spec.bundle_path): + bundle_url: str | None = f"/plugin-ui/{name}/index.js" + bundle_dir: Path | None = spec.bundle_path.resolve().parent + logger.info("Registered studio plugin %r at %s", name, bundle_url) + else: + bundle_url = None + bundle_dir = None + logger.info("Registered plugin %r (no web bundle)", name) + manifests.append(PluginManifestResponse(name=name, bundle_url=bundle_url, bundle_dir=bundle_dir)) + + return manifests + + +def build_plugins_router(manifests: list[PluginManifestResponse]) -> APIRouter: + """Build the FastAPI router for the /apis/plugins endpoint. + + Args: + manifests: Pre-computed list of plugin manifests. Pass the result of + ``discover_plugins()`` at startup so discovery runs once. + """ + router = APIRouter() + _manifests_out = [_PluginManifestOut(name=m.name, bundleUrl=m.bundle_url) for m in manifests] + + @router.get("/apis/plugins", tags=["Studio Plugins"]) + async def list_plugins() -> list[_PluginManifestOut]: + """List installed Studio web UI plugins.""" + return _manifests_out + + return router diff --git a/services/studio/src/nmp/studio/service.py b/services/studio/src/nmp/studio/service.py index 6cfc9d68b6..9cdb37de13 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -3,19 +3,22 @@ """Studio service implementation for serving the NeMo Studio UI.""" +from __future__ import annotations + import logging from collections.abc import Mapping from html import escape from pathlib import Path -from typing import ClassVar, List +from typing import ClassVar -from fastapi import FastAPI, Request, status -from fastapi.responses import HTMLResponse +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.responses import FileResponse, HTMLResponse from nmp.common.http_clients import shared_async_http_client from nmp.common.service import RouterConfig, Service from nmp.studio import copilot from nmp.studio.config import StudioConfig -from nmp.studio.static_files import SPAStaticFiles +from nmp.studio.plugins import build_plugins_router, discover_plugins +from nmp.studio.static_files import SPAStaticFiles, build_csp from starlette.responses import Response logger = logging.getLogger(__name__) @@ -40,6 +43,17 @@ } +def _bundle_asset_media_type(filename: str) -> str | None: + """Media type for an allowed plugin bundle asset; None for disallowed suffixes.""" + if filename.endswith(".js.map"): + return "application/json" + if filename.endswith(".js"): + return "text/javascript" + if filename.endswith(".css"): + return "text/css" + return None + + class StudioService(Service[StudioConfig]): """Studio service for serving the NeMo Studio UI static assets. @@ -67,7 +81,7 @@ def description(self) -> str: """Service description for OpenAPI docs.""" return "Serves the NeMo Studio web application and local copilot bridge" - def get_routers(self) -> List[RouterConfig]: + def get_routers(self) -> list[RouterConfig]: """Return routers for the studio service. Studio exposes API routes for local-only UI integrations in addition to @@ -93,6 +107,7 @@ def configure_app(self, app: FastAPI) -> None: self._mount_telemetry_proxy(app) self._mount_copilot_mcp(app) self._mount_static_files(app) + self._configure_plugins(app) def _mount_copilot_mcp(self, app: FastAPI) -> None: """Mount the auth-bypassed MCP callback before the /studio static app.""" @@ -223,6 +238,23 @@ def _response_headers(headers: Mapping[str, str]) -> dict[str, str]: if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() in {"content-type", "content-encoding"} } + def _build_csp_header(self) -> str: + """CSP extended with the cross-origin endpoints the SPA is configured to reach.""" + replacements = self._get_config().env_replacements + issuer = replacements.get("STUDIO_UI_VITE_AUTH_AUTHORITY", "") + platform_base_url = replacements.get("STUDIO_UI_VITE_PLATFORM_BASE_URL", "") + return build_csp( + connect_src_urls=( + issuer, + platform_base_url, + replacements.get("STUDIO_UI_VITE_DATA_STORE_MICROSERVICE_URL", ""), + replacements.get("STUDIO_UI_VITE_NIM_PROXY_MICROSERVICE_URL", ""), + replacements.get("STUDIO_UI_VITE_NIM_PROXY_MICROSERVICE_INTERNAL_URL", ""), + ), + frame_src_urls=(issuer,), # oidc-client-ts silent-renew iframe + script_src_urls=(platform_base_url,), # dynamic import() of plugin bundles + ) + def _mount_static_files(self, app: FastAPI) -> None: """Mount static files on the given FastAPI app. @@ -231,14 +263,13 @@ def _mount_static_files(self, app: FastAPI) -> None: """ static_path = self._get_static_files_path() if self._static_assets_ready(static_path): - # Get env replacements from config (single source of truth, cached) - env_replacements = self._get_config().env_replacements app.mount( "/studio", SPAStaticFiles( directory=str(static_path), html=True, - env_replacements=env_replacements, + env_replacements=self._get_config().env_replacements, + csp_header=self._build_csp_header(), ), name="studio-static", ) @@ -292,6 +323,42 @@ def _missing_static_files_response(static_path: Path, requested_path: str = "") headers={"Cache-Control": "no-store"}, ) + def _configure_plugins(self, app: FastAPI) -> None: + """Discover studio plugins and wire up their bundle assets and API endpoint.""" + manifests = discover_plugins() + + bundle_dirs: dict[str, Path] = {} + for manifest in manifests: + plugin_dir = manifest.bundle_dir + if plugin_dir is None: + logger.debug("Plugin %r has no web bundle — skipping bundle assets", manifest.name) + elif plugin_dir.exists(): + bundle_dirs[manifest.name] = plugin_dir + logger.info("Serving plugin bundle assets for %r at /plugin-ui/%s", manifest.name, manifest.name) + else: + logger.warning( + "Plugin %r bundle directory %r not found — bundle assets not served", + manifest.name, + plugin_dir, + ) + + @app.get("/plugin-ui/{plugin_name}/{filename}", include_in_schema=False) + async def serve_plugin_asset(plugin_name: str, filename: str) -> FileResponse: + # Allowlist: only direct children of the bundle dir with bundle-asset + # suffixes — never the plugin's Python source or subdirectories. + bundle_dir = bundle_dirs.get(plugin_name) + media_type = _bundle_asset_media_type(filename) + if bundle_dir is None or media_type is None or "/" in filename or "\\" in filename: + raise HTTPException(status_code=404) + # resolve() + parent check stops a symlink inside the bundle dir from + # escaping it and serving an arbitrary file over this public route. + file_path = (bundle_dir / filename).resolve() + if file_path.parent != bundle_dir.resolve() or not file_path.is_file(): + raise HTTPException(status_code=404) + return FileResponse(file_path, media_type=media_type) + + app.include_router(build_plugins_router(manifests)) + def _get_static_files_path(self) -> Path: """Get the path to the static files directory. diff --git a/services/studio/src/nmp/studio/static_files.py b/services/studio/src/nmp/studio/static_files.py index 7ad4a7455a..c2bc573b5e 100644 --- a/services/studio/src/nmp/studio/static_files.py +++ b/services/studio/src/nmp/studio/static_files.py @@ -3,10 +3,14 @@ """SPA-aware static file serving for the Studio UI.""" +import base64 +import hashlib import logging import os import re +from collections.abc import Iterable from pathlib import Path +from urllib.parse import urlparse from starlette.responses import Response from starlette.staticfiles import StaticFiles @@ -17,6 +21,103 @@ # Pattern to match any STUDIO_UI_* markers for cleanup STUDIO_UI_MARKER_PATTERN = re.compile(r"STUDIO_UI_[A-Z_]+") +# Finds inline blocks so their content +# hash can be authorized in script-src without weakening the policy with +# 'unsafe-inline'. The DOTALL flag lets . span newlines; non-greedy capture +# avoids merging multiple script tags. +_INLINE_IMPORTMAP_PATTERN = re.compile( + r'(.*?)', + re.DOTALL, +) + + +def _origin(url: str) -> str | None: + """scheme://host[:port] of an absolute http(s) URL; None for empty/relative values.""" + parsed = urlparse(url) + if parsed.scheme in ("http", "https") and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + return None + + +def _origins(urls: Iterable[str]) -> list[str]: + """Unique origins of the absolute URLs in *urls*, preserving order.""" + result: list[str] = [] + for url in urls: + origin = _origin(url) + if origin is not None and origin not in result: + result.append(origin) + return result + + +def build_csp( + connect_src_urls: Iterable[str] = (), + frame_src_urls: Iterable[str] = (), + script_src_urls: Iterable[str] = (), +) -> str: + """Content-Security-Policy for the Studio UI, built from configured endpoints. + + script-src 'self' plus per-content SHA-256 hashes (appended at startup): + Covers Studio JS chunks and plugin bundles (/plugin-ui/…) plus the inline + , + including whitespace, so callers must pass the unmodified inner text. + """ + digest = hashlib.sha256(content.encode("utf-8")).digest() + return f"'sha256-{base64.b64encode(digest).decode('ascii')}'" + + +def _augment_csp_for_inline_scripts(csp: str, html: str) -> str: + """Add sha256 sources to script-src for each inline import-map script.""" + hashes = [_sha256_script_source(match.group(1)) for match in _INLINE_IMPORTMAP_PATTERN.finditer(html)] + if not hashes: + return csp + # Inject the hashes into the script-src directive. Preserve the rest of + # the policy verbatim so any other directive tweaks survive. + addition = " " + " ".join(hashes) + replaced, count = re.subn( + r"(script-src[^;]*)", + lambda m: m.group(1) + addition, + csp, + count=1, + ) + return replaced if count else csp + "; script-src 'self'" + addition + class SPAStaticFiles(StaticFiles): """ @@ -31,12 +132,14 @@ class SPAStaticFiles(StaticFiles): - Falls back to index.html for non-file routes (SPA routing) - Handles .html extension stripping for clean URLs - Injects runtime environment variables from platform config (pre-processed once at startup) + - Attaches Content-Security-Policy header to HTML responses """ def __init__( self, *args, env_replacements: dict[str, str] | None = None, + csp_header: str | None = DEFAULT_CSP, **kwargs, ): """Initialize SPA static files handler. @@ -44,13 +147,46 @@ def __init__( Args: env_replacements: Optional dict of STUDIO_UI_* markers to replacement values. These will be applied to HTML and JS files once at startup. + csp_header: Content-Security-Policy header value to attach to HTML responses. + Pass None to disable CSP (not recommended in production). + Defaults to DEFAULT_CSP. """ super().__init__(*args, **kwargs) self._env_replacements = env_replacements or {} + self._csp_header = csp_header # Cache for pre-processed file contents (path -> processed content) self._processed_cache: dict[str, str] = {} # Pre-process files that need env var replacement self._preprocess_files() + # After preprocessing, derive per-response CSPs that authorize the + # HTML's inline ', + encoding="utf-8", + ) + app = FastAPI() + app.mount( + "/studio", + SPAStaticFiles(directory=str(tmp_path), html=True, csp_header=DEFAULT_CSP), + name="studio-static", + ) + c = TestClient(app) + response = c.get("/studio/") + csp = response.headers["Content-Security-Policy"] + expected_hash = base64.b64encode(hashlib.sha256(importmap_content.encode()).digest()).decode() + assert f"'sha256-{expected_hash}'" in csp + assert "script-src 'self' 'sha256-" in csp + + def test_no_csp_when_disabled(self, static_dir: Path): + app = FastAPI() + app.mount( + "/studio", + SPAStaticFiles(directory=str(static_dir), html=True, csp_header=None), + name="studio-static", + ) + c = TestClient(app) + response = c.get("/studio/") + assert response.status_code == 200 + assert "Content-Security-Policy" not in response.headers + + +def _directive(csp: str, name: str) -> str: + """Return the value of the named CSP directive, e.g. _directive(csp, 'script-src').""" + for part in csp.split(";"): + part = part.strip() + if part.startswith(f"{name} "): + return part[len(name) + 1 :] + raise AssertionError(f"directive {name!r} not found in {csp!r}") + + +class TestBuildCSP: + """build_csp folds configured cross-origin endpoints into the right directives.""" + + def test_no_args_is_fully_same_origin(self): + csp = build_csp() + assert csp == DEFAULT_CSP + assert _directive(csp, "connect-src") == "'self'" + assert _directive(csp, "script-src") == "'self'" + assert _directive(csp, "frame-src") == "'none'" + + def test_connect_src_url_contributes_origin(self): + csp = build_csp(connect_src_urls=("https://api.example.com",)) + assert _directive(csp, "connect-src") == "'self' https://api.example.com" + assert _directive(csp, "script-src") == "'self'" + + def test_script_src_url_contributes_origin(self): + csp = build_csp(script_src_urls=("https://cdn.example.com",)) + assert _directive(csp, "script-src") == "'self' https://cdn.example.com" + + def test_frame_src_switches_from_none_to_self_plus_origin(self): + csp = build_csp(frame_src_urls=("https://issuer.example.com",)) + assert _directive(csp, "frame-src") == "'self' https://issuer.example.com" + + def test_only_scheme_host_port_kept_not_path_or_query(self): + """A path/query in a configured URL must not leak into the directive.""" + csp = build_csp(connect_src_urls=("https://api.example.com:8443/apis/v2?x=1",)) + assert _directive(csp, "connect-src") == "'self' https://api.example.com:8443" + + def test_empty_and_relative_urls_contribute_nothing(self): + csp = build_csp( + connect_src_urls=("", "/apis/plugins", "not-a-url", "ftp://x/y"), + script_src_urls=("",), + frame_src_urls=("",), + ) + assert _directive(csp, "connect-src") == "'self'" + assert _directive(csp, "script-src") == "'self'" + assert _directive(csp, "frame-src") == "'none'" + + def test_origins_deduped_preserving_order(self): + csp = build_csp( + connect_src_urls=( + "https://a.example.com/one", + "https://b.example.com", + "https://a.example.com/two", + ) + ) + assert _directive(csp, "connect-src") == "'self' https://a.example.com https://b.example.com" class TestHasFileExtension: diff --git a/web/package.json b/web/package.json index 3d864627e2..b7b1709a95 100644 --- a/web/package.json +++ b/web/package.json @@ -45,7 +45,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-testing-library": "^7.15.4", "eslint-plugin-unused-imports": "^4.1.4", - "globals": "^15.15.0", + "globals": "^17.7.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", "prettier": "^3.6.2", diff --git a/web/packages/studio/.gitignore b/web/packages/studio/.gitignore index e96b55268f..c7aa31a5cb 100644 --- a/web/packages/studio/.gitignore +++ b/web/packages/studio/.gitignore @@ -18,6 +18,7 @@ lerna-debug.log* node_modules dist dist-ssr +public/vendor *.local vite.config.*.timestamp-* diff --git a/web/packages/studio/env/.env.dev.local.sample b/web/packages/studio/env/.env.dev.local.sample index c1aea645f8..6613af097f 100644 --- a/web/packages/studio/env/.env.dev.local.sample +++ b/web/packages/studio/env/.env.dev.local.sample @@ -40,6 +40,7 @@ VITE_FF_JOBS_ENABLED='true' VITE_FF_MEMBERS_ENABLED='preview' VITE_FF_MODEL_COMPARE_ENABLED='true' VITE_FF_OPTIMIZER_ENABLED='true' +VITE_FF_PLUGINS_ENABLED='true' VITE_FF_SAFE_SYNTHESIZER_ENABLED='true' VITE_FF_SECRETS_ENABLED='true' VITE_FF_SETTINGS_ENABLED='true' diff --git a/web/packages/studio/env/.env.fastapi b/web/packages/studio/env/.env.fastapi index 5aaad3e4af..51c6b86834 100644 --- a/web/packages/studio/env/.env.fastapi +++ b/web/packages/studio/env/.env.fastapi @@ -34,5 +34,6 @@ VITE_FF_INTAKE_ENABLED=STUDIO_UI_VITE_FF_INTAKE_ENABLED VITE_FF_MEMBERS_ENABLED=STUDIO_UI_VITE_FF_MEMBERS_ENABLED VITE_FF_MODEL_COMPARE_ENABLED=STUDIO_UI_VITE_FF_MODEL_COMPARE_ENABLED VITE_FF_OPTIMIZER_ENABLED=STUDIO_UI_VITE_FF_OPTIMIZER_ENABLED +VITE_FF_PLUGINS_ENABLED=STUDIO_UI_VITE_FF_PLUGINS_ENABLED VITE_FF_SAFE_SYNTHESIZER_ENABLED=STUDIO_UI_VITE_FF_SAFE_SYNTHESIZER_ENABLED VITE_FF_SECRETS_ENABLED=STUDIO_UI_VITE_FF_SECRETS_ENABLED diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index 2d3fd9d5a4..6e2a78bca6 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -114,6 +114,8 @@ "@vitest/coverage-v8": "catalog:", "@vitest/ui": "catalog:", "blob-polyfill": "^9.0.20240710", + "globals": "^17.7.0", + "rolldown": "^1.1.4", "happy-dom": "catalog:", "js-yaml": "^4.3.0", "jsdom": "catalog:", diff --git a/web/packages/studio/src/constants/environment.ts b/web/packages/studio/src/constants/environment.ts index e994b139c1..cc85917f58 100644 --- a/web/packages/studio/src/constants/environment.ts +++ b/web/packages/studio/src/constants/environment.ts @@ -49,6 +49,7 @@ export const JOBS_ENABLED = featureFlags.jobsEnabled !== false; export const MEMBERS_ENABLED = featureFlags.membersEnabled !== false; export const MODEL_COMPARE_ENABLED = featureFlags.modelCompareEnabled !== false; export const OPTIMIZER_ENABLED = featureFlags.optimizerEnabled !== false; +export const PLUGINS_ENABLED = featureFlags.pluginsEnabled !== false; export const SAFE_SYNTHESIZER_ENABLED = featureFlags.safeSynthesizerEnabled !== false; export const SECRETS_ENABLED = featureFlags.secretsEnabled !== false; export const SETTINGS_ENABLED = featureFlags.settingsEnabled !== false; diff --git a/web/packages/studio/src/constants/featureFlags/featureFlags.ts b/web/packages/studio/src/constants/featureFlags/featureFlags.ts index 2f5a22a953..d9770c2b1a 100644 --- a/web/packages/studio/src/constants/featureFlags/featureFlags.ts +++ b/web/packages/studio/src/constants/featureFlags/featureFlags.ts @@ -74,6 +74,7 @@ export const flagDefinitions = { membersEnabled: previewFlag('VITE_FF_MEMBERS_ENABLED'), modelCompareEnabled: previewFlag('VITE_FF_MODEL_COMPARE_ENABLED'), optimizerEnabled: previewFlag('VITE_FF_OPTIMIZER_ENABLED', true), + pluginsEnabled: previewFlag('VITE_FF_PLUGINS_ENABLED', true), safeSynthesizerEnabled: previewFlag('VITE_FF_SAFE_SYNTHESIZER_ENABLED', true), secretsEnabled: previewFlag('VITE_FF_SECRETS_ENABLED', true), settingsEnabled: previewFlag('VITE_FF_SETTINGS_ENABLED', true), diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 6da5a8bc8b..5a15820945 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -43,6 +43,7 @@ export const ROUTE_PARAMS = { evaluationName: 'evaluationName', guardrailConfigName: 'guardrailConfigName', guardrailChecksSubTab: 'guardrailChecksSubTab', + pluginName: 'pluginName', } as const; // Just an alias to make the routes more readable @@ -136,6 +137,8 @@ export const ROUTES = { agentEvaluationDetail: `/workspaces/:${P.workspace}/agents/evaluations/:${P.agentEvalJobName}`, modelCompare: `/workspaces/:${P.workspace}/playground`, agentMonitor: `/workspaces/:${P.workspace}/agents/monitor`, + /** Plugin-owned page; the plugin's internal router owns sub-paths via a `/*` suffix. */ + plugin: `/workspaces/:${P.workspace}/plugin/:${P.pluginName}`, }, models: { index: '/models', diff --git a/web/packages/studio/src/plugins/PluginContext.test.tsx b/web/packages/studio/src/plugins/PluginContext.test.tsx new file mode 100644 index 0000000000..afe72317db --- /dev/null +++ b/web/packages/studio/src/plugins/PluginContext.test.tsx @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + usePluginInstalled, + usePlugins, + usePluginsError, + usePluginsLoaded, +} from '@studio/plugins/PluginContext'; +import { PluginProvider } from '@studio/plugins/PluginProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +const createWrapper = (retry: number | false = false) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry, retryDelay: 0 } }, + }); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +}; + +const usePluginState = () => ({ + plugins: usePlugins(), + isLoaded: usePluginsLoaded(), + isError: usePluginsError(), + agentsInstalled: usePluginInstalled('agents'), +}); + +beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); +}); + +describe('PluginProvider', () => { + it('starts with empty plugins', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [], + } as Response); + + const { result } = renderHook(usePluginState, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoaded).toBe(true); + }); + expect(result.current.plugins).toEqual([]); + expect(result.current.isError).toBe(false); + }); + + it('skips plugins with untrusted bundle URLs', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [{ name: 'evil', bundleUrl: 'https://evil.com/malicious.js' }], + } as Response); + + const { result } = renderHook(usePluginState, { wrapper: createWrapper() }); + + // Wait for the query to settle — security gate rejects the URL before import() + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Rejected untrusted bundle URL') + ); + }); + expect(result.current.plugins).toHaveLength(0); + warnSpy.mockRestore(); + }); + + it('exposes an error state when fetch fails', async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error('network error')); + + const { result } = renderHook(usePluginState, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + expect(result.current.isLoaded).toBe(true); + expect(result.current.plugins).toHaveLength(0); + }); + + it('exposes an error state when response is not ok', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 503, + json: async () => [], + } as unknown as Response); + + const { result } = renderHook(usePluginState, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + expect(result.current.isLoaded).toBe(true); + expect(result.current.plugins).toHaveLength(0); + }); + + it('recovers when a transient fetch failure is retried', async () => { + vi.mocked(global.fetch) + .mockRejectedValueOnce(new Error('502 Bad Gateway')) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ name: 'agents', bundleUrl: null }], + } as Response); + + const { result } = renderHook(usePluginState, { wrapper: createWrapper(1) }); + + await waitFor(() => { + expect(result.current.isLoaded).toBe(true); + }); + expect(result.current.isError).toBe(false); + expect(result.current.agentsInstalled).toBe(true); + }); + + it('warns when /apis/plugins does not return an array', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ plugins: [] }), // object, not array + } as unknown as Response); + + const { result } = renderHook(usePluginState, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('did not return an array')); + }); + // Fails open: a malformed manifest surfaces as an error, not empty success. + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + expect(result.current.plugins).toHaveLength(0); + warnSpy.mockRestore(); + }); +}); diff --git a/web/packages/studio/src/plugins/PluginContext.ts b/web/packages/studio/src/plugins/PluginContext.ts new file mode 100644 index 0000000000..3011503904 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginContext.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { LoadedPlugin, PluginContextValue } from '@studio/plugins/types'; +import { createContext, useContext } from 'react'; + +export const PluginContext = createContext({ + plugins: [], + installedNames: new Set(), + isLoaded: false, + isError: false, +}); + +export const usePlugins = (): LoadedPlugin[] => useContext(PluginContext).plugins; +export const usePluginsLoaded = (): boolean => useContext(PluginContext).isLoaded; +/** Returns true if the plugin manifest could not be fetched. */ +export const usePluginsError = (): boolean => useContext(PluginContext).isError; +/** Returns true if the named plugin is registered in /apis/plugins (with or without a bundle). */ +export const usePluginInstalled = (name: string): boolean => + useContext(PluginContext).installedNames.has(name); diff --git a/web/packages/studio/src/plugins/PluginErrorBoundary.tsx b/web/packages/studio/src/plugins/PluginErrorBoundary.tsx new file mode 100644 index 0000000000..6f078f06c6 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginErrorBoundary.tsx @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage'; +import { Button, PageHeader, Panel, Stack } from '@nvidia/foundations-react-core'; +import { logger } from '@studio/util/logger'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface PluginErrorBoundaryProps { + // Changing this resets the boundary. + pluginName: string; + children: ReactNode; +} + +interface PluginErrorBoundaryState { + error: Error | null; +} + +// Contains a plugin's render errors to its own panel so a throw in third-party +// plugin code can't unwind past Studio's layout. +export class PluginErrorBoundary extends Component< + PluginErrorBoundaryProps, + PluginErrorBoundaryState +> { + state: PluginErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): PluginErrorBoundaryState { + return { error }; + } + + componentDidUpdate(prevProps: PluginErrorBoundaryProps): void { + if (this.state.error && prevProps.pluginName !== this.props.pluginName) { + this.setState({ error: null }); + } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + logger.error( + `Plugin "${this.props.pluginName}" crashed during render: ${error.message}`, + info.componentStack + ); + } + + private reset = (): void => this.setState({ error: null }); + + render(): ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + + return ( + + + + + Try Again + + } + /> + + + ); + } +} diff --git a/web/packages/studio/src/plugins/PluginProvider.tsx b/web/packages/studio/src/plugins/PluginProvider.tsx new file mode 100644 index 0000000000..deefed9587 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginProvider.tsx @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NO_NAMES, NO_PLUGINS, PLUGINS_MANIFEST_QUERY_KEY } from '@studio/plugins/consts'; +import { PluginContext } from '@studio/plugins/PluginContext'; +import type { PluginProviderProps } from '@studio/plugins/types'; +import { fetchPlugins } from '@studio/plugins/utils'; +import { useQuery } from '@tanstack/react-query'; + +export const PluginProvider = ({ children }: PluginProviderProps) => { + const { data, isSuccess, isError } = useQuery({ + queryKey: PLUGINS_MANIFEST_QUERY_KEY, + queryFn: fetchPlugins, + staleTime: Infinity, + gcTime: Infinity, + refetchOnReconnect: false, + }); + + return ( + + {children} + + ); +}; diff --git a/web/packages/studio/src/plugins/PluginRenderer.test.tsx b/web/packages/studio/src/plugins/PluginRenderer.test.tsx new file mode 100644 index 0000000000..8dce171e48 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginRenderer.test.tsx @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { usePlugins, usePluginsLoaded } from '@studio/plugins/PluginContext'; +import { PluginRenderer } from '@studio/plugins/PluginRenderer'; +import type { LoadedPlugin, PluginRootProps } from '@studio/plugins/types'; +import { render, screen } from '@testing-library/react'; +import { useEffect } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const authState = vi.hoisted(() => ({ accessToken: 'test-token' })); + +let capturedProps: PluginRootProps | undefined; +const mountSpy = vi.fn(); +const mockNavItems = vi.fn(() => []); + +function MockRoot(props: PluginRootProps) { + capturedProps = props; + useEffect(() => { + mountSpy(); + }, []); + return ( +
+ ws:{props.host.workspaceId} token:{props.host.auth.accessToken} +
+ ); +} + +function makePlugin(name: string): LoadedPlugin { + return { name, Root: MockRoot, navItems: mockNavItems }; +} + +vi.mock('@studio/plugins/PluginContext', () => ({ + usePlugins: vi.fn(), + usePluginsLoaded: vi.fn(), +})); +vi.mock('@nemo/common/src/providers/toast/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); +vi.mock('react-oidc-context', () => ({ + useAuth: vi.fn(() => ({ user: { access_token: authState.accessToken } })), +})); + +vi.mock('@studio/hooks/useWorkspaceFromPath', () => ({ + useWorkspaceFromPath: vi.fn(() => 'my-workspace'), +})); + +function renderPlugin(pluginName = 'test-plugin') { + return render( + + + } /> + + + ); +} + +beforeEach(() => { + authState.accessToken = 'test-token'; + capturedProps = undefined; + mountSpy.mockReset(); + vi.mocked(usePluginsLoaded).mockReturnValue(true); +}); + +describe('PluginRenderer', () => { + it('shows loading state while plugins have not finished loading', () => { + vi.mocked(usePluginsLoaded).mockReturnValue(false); + vi.mocked(usePlugins).mockReturnValue([]); + + render( + + + } /> + + + ); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('renders the plugin Root with workspaceId and accessToken when loaded', () => { + vi.mocked(usePlugins).mockReturnValue([makePlugin('test-plugin')]); + + renderPlugin(); + + expect(screen.getByTestId('plugin-root')).toBeInTheDocument(); + expect(capturedProps?.host.workspaceId).toBe('my-workspace'); + expect(capturedProps?.host.auth.accessToken).toBe('test-token'); + expect(capturedProps?.host.auth.getAccessToken()).toBe('test-token'); + expect(typeof capturedProps?.host.sdk.platform.useEntitiesListWorkspaces).toBe('function'); + expect(typeof capturedProps?.host.navigation.navigate).toBe('function'); + expect(typeof capturedProps?.host.notifications.notify).toBe('function'); + expect(typeof capturedProps?.host.telemetry.event).toBe('function'); + }); + + it('does not remount on token renewal and getAccessToken returns the new token', () => { + vi.mocked(usePlugins).mockReturnValue([makePlugin('test-plugin')]); + + const { rerender } = renderPlugin(); + expect(mountSpy).toHaveBeenCalledTimes(1); + + authState.accessToken = 'renewed-token'; + rerender( + + + } /> + + + ); + + expect(mountSpy).toHaveBeenCalledTimes(1); + expect(capturedProps?.host.auth.getAccessToken()).toBe('renewed-token'); + }); + + it('shows not found when plugin name does not match any loaded plugin', () => { + vi.mocked(usePlugins).mockReturnValue([makePlugin('other-plugin')]); + + renderPlugin('missing-plugin'); + + expect(screen.getByText(/not found/i)).toBeInTheDocument(); + }); + + it('contains a plugin render error in the plugin panel instead of unwinding', () => { + function ThrowingRoot(): never { + throw new Error('boom from plugin'); + } + vi.mocked(usePlugins).mockReturnValue([ + { name: 'test-plugin', Root: ThrowingRoot, navItems: mockNavItems }, + ]); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderPlugin(); + + expect(screen.getByText(/this plugin ran into a problem/i)).toBeInTheDocument(); + expect(screen.getByText(/boom from plugin/)).toBeInTheDocument(); + expect(screen.queryByTestId('plugin-root')).not.toBeInTheDocument(); + + consoleError.mockRestore(); + }); +}); diff --git a/web/packages/studio/src/plugins/PluginRenderer.tsx b/web/packages/studio/src/plugins/PluginRenderer.tsx new file mode 100644 index 0000000000..0d4f2db4e3 --- /dev/null +++ b/web/packages/studio/src/plugins/PluginRenderer.tsx @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import * as platformSdk from '@nemo/sdk/generated/platform/api'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { usePlugins, usePluginsLoaded } from '@studio/plugins/PluginContext'; +import { PluginErrorBoundary } from '@studio/plugins/PluginErrorBoundary'; +import type { PluginHost, PluginSdk, PluginTelemetry } from '@studio/plugins/types'; +import { logger } from '@studio/util/logger'; +import { useCallback, useMemo, useRef, type ReactElement } from 'react'; +import { useAuth } from 'react-oidc-context'; +import { useNavigate, useParams } from 'react-router-dom'; + +// Module-scope for stable identity; plugins run these on Studio's axios + cache. +const STUDIO_SDK: PluginSdk = { platform: platformSdk }; + +const makeTelemetry = (name: string): PluginTelemetry => ({ + info: (message, cause) => logger.info(`[plugin:${name}] ${message}`, cause), + warn: (message, cause) => logger.warn(`[plugin:${name}] ${message}`, cause), + error: (message, cause) => logger.error(`[plugin:${name}] ${message}`, cause), + event: (event, attributes) => logger.info(`[plugin:${name}] event:${event}`, attributes), +}); + +// Renders the active plugin's `Root` as a normal child (not a detached +// `createRoot`) so it shares Studio's Router, QueryClient, and theme. +export const PluginRenderer = (): ReactElement => { + const { pluginName } = useParams<{ pluginName: string }>(); + const plugins = usePlugins(); + const isLoaded = usePluginsLoaded(); + const workspace = useWorkspaceFromPath(); + const { user } = useAuth(); + const navigate = useNavigate(); + const toast = useToast(); + + const plugin = plugins.find((p) => p.name === pluginName); + const accessToken = user?.access_token ?? ''; + // Keep the latest token in a ref so getAccessToken has a stable identity but + // still returns the current token after OIDC silent renew. + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + const getAccessToken = useCallback(() => accessTokenRef.current, []); + + const host = useMemo( + () => ({ + workspaceId: workspace, + auth: { accessToken, getAccessToken }, + sdk: STUDIO_SDK, + navigation: { navigate: (to) => navigate(to), back: () => navigate(-1) }, + notifications: { notify: (message, type = 'info') => toast[type](message) }, + telemetry: makeTelemetry(pluginName ?? 'unknown'), + }), + [workspace, accessToken, getAccessToken, navigate, toast, pluginName] + ); + + if (!isLoaded) { + return ( +
Loading plugin…
+ ); + } + + if (!plugin) { + return ( +
+ Plugin “{pluginName}” not found. +
+ ); + } + + const { Root } = plugin; + return ( +
+ + + +
+ ); +}; diff --git a/web/packages/studio/src/plugins/consts.ts b/web/packages/studio/src/plugins/consts.ts new file mode 100644 index 0000000000..b441ba3ce4 --- /dev/null +++ b/web/packages/studio/src/plugins/consts.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { LoadedPlugin } from '@studio/plugins/types'; + +/** Platform endpoint returning the installed plugin manifest. */ +export const PLUGINS_MANIFEST_ENDPOINT = '/apis/plugins'; + +/** react-query key for the plugin manifest fetch. */ +export const PLUGINS_MANIFEST_QUERY_KEY = ['plugins', 'manifest'] as const; + +/** + * Referentially-stable empty defaults so context consumers don't re-render on + * every provider render before the manifest has loaded. + */ +export const NO_PLUGINS: LoadedPlugin[] = []; +export const NO_NAMES: ReadonlySet = new Set(); diff --git a/web/packages/studio/src/plugins/iconMap.test.ts b/web/packages/studio/src/plugins/iconMap.test.ts new file mode 100644 index 0000000000..73b5b7e1bd --- /dev/null +++ b/web/packages/studio/src/plugins/iconMap.test.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getPluginIcon } from '@studio/plugins/iconMap'; + +describe('getPluginIcon', () => { + it('returns a known Lucide component for a valid kebab-case name', () => { + const icon = getPluginIcon('flask-conical'); + expect(icon).toBeDefined(); + // Lucide components may be objects or functions depending on the version + expect(icon).toBeTruthy(); + }); + + it('returns a known single-word icon', () => { + const icon = getPluginIcon('settings'); + expect(icon).toBeDefined(); + }); + + it('returns undefined for an unknown icon name', () => { + expect(getPluginIcon('this-icon-does-not-exist')).toBeUndefined(); + }); + + it('returns undefined for non-icon lucide exports like the generic Icon', () => { + expect(getPluginIcon('icon')).toBeUndefined(); + }); + + it('returns undefined for an empty string', () => { + expect(getPluginIcon('')).toBeUndefined(); + }); + + it('returns undefined for a name with a trailing hyphen', () => { + expect(getPluginIcon('flask-')).toBeUndefined(); + }); +}); diff --git a/web/packages/studio/src/plugins/iconMap.ts b/web/packages/studio/src/plugins/iconMap.ts new file mode 100644 index 0000000000..d81f1f9ef3 --- /dev/null +++ b/web/packages/studio/src/plugins/iconMap.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { icons, type LucideIcon } from 'lucide-react'; + +/** + * Converts a kebab-case icon name to PascalCase and looks it up in the + * lucide-react `icons` record (real icons only — not helper exports like + * the generic `Icon` component). Returns undefined if not found. + * + * Example: "flask-conical" → FlaskConical component + */ +export function getPluginIcon(iconName: string): LucideIcon | undefined { + const pascalName = iconName + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + return (icons as Record)[pascalName]; +} diff --git a/web/packages/studio/src/plugins/security.test.ts b/web/packages/studio/src/plugins/security.test.ts new file mode 100644 index 0000000000..fde8329931 --- /dev/null +++ b/web/packages/studio/src/plugins/security.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isTrustedBundleUrl } from '@studio/plugins/security'; + +describe('isTrustedBundleUrl', () => { + it('accepts valid plugin bundle paths', () => { + expect(isTrustedBundleUrl('/plugin-ui/my-plugin/index.js')).toBe(true); + expect(isTrustedBundleUrl('/plugin-ui/nemo-agents/index.js')).toBe(true); + }); + + it('rejects single-character plugin names', () => { + expect(isTrustedBundleUrl('/plugin-ui/a/index.js')).toBe(false); + }); + + it('rejects absolute https URLs', () => { + expect(isTrustedBundleUrl('https://evil.com/malicious.js')).toBe(false); + }); + + it('rejects absolute http URLs', () => { + expect(isTrustedBundleUrl('http://evil.com/bundle.js')).toBe(false); + }); + + it('rejects paths not under /plugin-ui/', () => { + expect(isTrustedBundleUrl('/other/path/index.js')).toBe(false); + expect(isTrustedBundleUrl('/studio/plugin-ui/x/index.js')).toBe(false); + }); + + it('rejects paths with non-alphanumeric plugin names', () => { + expect(isTrustedBundleUrl('/plugin-ui/../../../etc/passwd')).toBe(false); + expect(isTrustedBundleUrl('/plugin-ui/evil%2F../index.js')).toBe(false); + }); + + it('rejects paths that do not end with /index.js', () => { + expect(isTrustedBundleUrl('/plugin-ui/my-plugin/bundle.js')).toBe(false); + expect(isTrustedBundleUrl('/plugin-ui/my-plugin/')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isTrustedBundleUrl('')).toBe(false); + }); +}); diff --git a/web/packages/studio/src/plugins/security.ts b/web/packages/studio/src/plugins/security.ts new file mode 100644 index 0000000000..d72ac4f6a4 --- /dev/null +++ b/web/packages/studio/src/plugins/security.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Security gate applied before dynamically importing a plugin bundle. + * Only paths served by the platform under /plugin-ui/ are trusted. + */ + +/** Plugin names must start with a letter and contain only lowercase alphanumeric chars and hyphens. */ +const VALID_BUNDLE_URL = /^\/plugin-ui\/[a-z][a-z0-9-]+\/index\.js$/; + +export function isTrustedBundleUrl(url: string): boolean { + return VALID_BUNDLE_URL.test(url); +} diff --git a/web/packages/studio/src/plugins/types.ts b/web/packages/studio/src/plugins/types.ts new file mode 100644 index 0000000000..9cc4014b26 --- /dev/null +++ b/web/packages/studio/src/plugins/types.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ComponentType, ReactNode } from 'react'; + +/** + * Studio's SDK, by service. Plugins call these hooks directly; they dispatch into + * Studio's React (a shared singleton), running on Studio's axios + QueryClient. + * Passed by prop so plugins need no build dependency on the private `@nemo/sdk`. + */ +export interface PluginSdk { + platform: typeof import('@nemo/sdk/generated/platform/api'); +} + +/** Navigate Studio's shared router; paths are absolute Studio routes. */ +export interface PluginNavigation { + navigate: (to: string) => void; + back: () => void; +} + +export type NotificationType = 'success' | 'error' | 'info' | 'warning'; + +/** Fire a toast into Studio's shared toaster; defaults to `info`. */ +export interface PluginNotifications { + notify: (message: string, type?: NotificationType) => void; +} + +/** Structured logging to Studio's OTEL pipeline, auto-scoped to the plugin. */ +export interface PluginTelemetry { + info: (message: string, cause?: unknown) => void; + warn: (message: string, cause?: unknown) => void; + error: (message: string, cause?: unknown) => void; + event: (name: string, attributes?: Record) => void; +} + +/** The host handle Studio injects into every plugin; extend it to add capabilities. */ +export interface PluginHost { + workspaceId: string; + // Access tokens only — refresh tokens must not cross the boundary. + auth: { + accessToken: string; + getAccessToken: () => string; + }; + sdk: PluginSdk; + navigation: PluginNavigation; + notifications: PluginNotifications; + telemetry: PluginTelemetry; +} + +export interface PluginRootProps { + host: PluginHost; +} + +/** API manifest returned by `GET /apis/plugins`. */ +export interface PluginManifest { + name: string; + /** `null` for plugins registered without a web bundle. */ + bundleUrl: string | null; +} + +/** A single navigation item contributed by a plugin. */ +export interface PluginNavItem { + id: string; + /** Kebab-case Lucide icon name, e.g. `"flask-conical"`. */ + iconName: string; + label: string; + /** + * Absolute path relative to the app root, e.g. + * `/workspaces/:workspaceId/plugin/example/dashboard`. + */ + href: string; +} + +/** A group of navigation items contributed by a plugin. */ +export interface PluginNavGroup { + group: string; + items: PluginNavItem[]; +} + +/** + * A plugin bundle loaded at runtime via dynamic `import()`. + * + * The plugin's `Root` is rendered *inside* Studio's React tree — under the same + * Router, QueryClient, and theme providers — so the plugin shares those contexts + * (e.g. it navigates with Studio's router instead of standing up its own). The + * plugin still ships as a separately-built bundle with its own private deps; only + * the context-bearing singletons (react, react-dom, react-router) are shared via + * the runtime import map. + */ +export interface LoadedPlugin { + name: string; + /** The plugin's root component, rendered within Studio's provider tree. */ + Root: ComponentType; + /** Return nav items scoped to the given workspace. */ + navItems: (workspaceId: string) => PluginNavGroup[]; +} + +/** The exports a loaded plugin bundle module must expose. */ +export interface PluginModule { + Root: LoadedPlugin['Root']; + navItems: LoadedPlugin['navItems']; +} + +/** Result of fetching the manifest and loading each plugin's bundle. */ +export interface PluginQueryData { + plugins: LoadedPlugin[]; + installedNames: ReadonlySet; +} + +/** Value exposed by the plugin React context. */ +export interface PluginContextValue { + plugins: LoadedPlugin[]; + /** All plugin names returned by /apis/plugins, including headless ones. */ + installedNames: ReadonlySet; + isLoaded: boolean; + isError: boolean; +} + +/** Props for the plugin context provider. */ +export interface PluginProviderProps { + children: ReactNode; +} diff --git a/web/packages/studio/src/plugins/utils.ts b/web/packages/studio/src/plugins/utils.ts new file mode 100644 index 0000000000..f6c08fcf2c --- /dev/null +++ b/web/packages/studio/src/plugins/utils.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import { PLUGINS_MANIFEST_ENDPOINT } from '@studio/plugins/consts'; +import { isTrustedBundleUrl } from '@studio/plugins/security'; +import type { + LoadedPlugin, + PluginManifest, + PluginModule, + PluginQueryData, +} from '@studio/plugins/types'; +import { logger } from '@studio/util/logger'; + +export function isValidPluginManifest(obj: unknown): obj is PluginManifest { + if (typeof obj !== 'object' || obj === null) return false; + const o = obj as Record; + return typeof o.name === 'string' && (typeof o.bundleUrl === 'string' || o.bundleUrl === null); +} + +export function isPluginModule(mod: unknown): mod is PluginModule { + if (typeof mod !== 'object' || mod === null) return false; + const m = mod as Record; + return typeof m.Root === 'function' && typeof m.navItems === 'function'; +} + +export async function loadPlugin( + manifest: PluginManifest, + baseUrl: string +): Promise { + if (manifest.bundleUrl === null) { + // Plugin registered without a web bundle — no UI to mount or nav to show. + return null; + } + if (!isTrustedBundleUrl(manifest.bundleUrl)) { + logger.warn(`[plugins] Rejected untrusted bundle URL: ${manifest.bundleUrl}`); + return null; + } + // Prefix with the API base URL so the import resolves against the backend + // host, not the Studio dev server (which may run on a different origin). + const absoluteUrl = `${baseUrl}${manifest.bundleUrl}`; + try { + const module: unknown = await import(/* @vite-ignore */ absoluteUrl); + if (!isPluginModule(module)) { + logger.warn(`[plugins] Plugin "${manifest.name}" missing required exports (Root, navItems)`); + return null; + } + return { name: manifest.name, Root: module.Root, navItems: module.navItems }; + } catch (err) { + logger.warn(`[plugins] Failed to load plugin "${manifest.name}":`, err); + return null; + } +} + +export async function fetchPlugins(): Promise { + // Falls back to same-origin when PLATFORM_BASE_URL is not configured + const baseUrl = PLATFORM_BASE_URL ?? ''; + const res = await fetch(`${baseUrl}${PLUGINS_MANIFEST_ENDPOINT}`); + if (!res.ok) throw new Error(`${PLUGINS_MANIFEST_ENDPOINT} returned ${res.status}`); + const data: unknown = await res.json(); + if (!Array.isArray(data)) { + // Throw (not empty-success) so a malformed manifest fails open like a + // network error, rather than masquerading as "no plugins installed". + logger.warn(`[plugins] ${PLUGINS_MANIFEST_ENDPOINT} did not return an array`); + throw new Error(`${PLUGINS_MANIFEST_ENDPOINT} did not return an array`); + } + const invalid = (data as unknown[]).filter((item) => !isValidPluginManifest(item)); + if (invalid.length > 0) { + logger.warn( + `[plugins] ${PLUGINS_MANIFEST_ENDPOINT} returned ${invalid.length} invalid manifest(s) — skipping` + ); + } + const manifests = (data as unknown[]).filter(isValidPluginManifest); + const loaded = await Promise.all(manifests.map((m) => loadPlugin(m, baseUrl))); + return { + installedNames: new Set(manifests.map((m) => m.name)), + plugins: loaded.filter((p): p is LoadedPlugin => p !== null), + }; +} diff --git a/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx b/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx index 436f792118..6471a62f25 100644 --- a/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx +++ b/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx @@ -24,6 +24,13 @@ import { SETTINGS_ENABLED, } from '@studio/constants/environment'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getPluginIcon } from '@studio/plugins/iconMap'; +import { + usePluginInstalled, + usePlugins, + usePluginsError, + usePluginsLoaded, +} from '@studio/plugins/PluginContext'; import { iconColorClass } from '@studio/routes/constants'; import { getAgentSideNavItems } from '@studio/routes/groups/agentRoutes'; import { @@ -45,6 +52,7 @@ import { getWorkspaceSettingsRoute, getWorkspaceVirtualModelsRoute, } from '@studio/routes/utils'; +import { logger } from '@studio/util/logger'; import { Beaker, Boxes, @@ -66,6 +74,12 @@ import { useMemo } from 'react'; export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { const workspace = useWorkspaceFromPath(); + const plugins = usePlugins(); + const agentsInstalled = usePluginInstalled('agents'); + const pluginsLoaded = usePluginsLoaded(); + const pluginsError = usePluginsError(); + const manifestResolved = pluginsLoaded && !pluginsError; + const showAgents = agentsInstalled || !manifestResolved; const items = useMemo(() => { const dashboardNav = @@ -166,7 +180,7 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { ] : []; - const agentItems = getAgentSideNavItems(workspace); + const agentItems = showAgents ? getAgentSideNavItems(workspace) : []; const optimizerNav = OPTIMIZER_ENABLED ? [ @@ -276,7 +290,7 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { ...(evaluateItems.length > 0 ? [{ group: 'Evaluate', items: evaluateItems }] : []), ...(safetyItems.length > 0 ? [{ group: 'Safety', items: safetyItems }] : []), ]; - }, [workspace]); + }, [workspace, showAgents]); const bottomItems = useMemo( () => [ @@ -294,5 +308,35 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { [workspace] ); - return ; + const pluginNavGroups = useMemo( + () => + plugins.flatMap((plugin) => { + try { + return plugin.navItems(workspace).map((group) => ({ + group: group.group, + items: group.items.map((item) => { + const Icon = getPluginIcon(item.iconName); + return { + id: item.id, + slotIcon: Icon ? : undefined, + slotLabel: item.label, + href: item.href, + }; + }), + })); + } catch (err) { + logger.warn(`[plugins] navItems() threw for plugin "${plugin.name}":`, err); + return []; + } + }), + [plugins, workspace] + ); + + return ( + + ); }; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx index 67e8ef2662..12274a1135 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.test.tsx @@ -15,6 +15,13 @@ import { within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; +vi.mock('@studio/plugins/PluginContext', async (importOriginal) => ({ + ...(await importOriginal()), + usePluginsLoaded: () => true, + usePluginsError: () => false, + usePluginInstalled: () => true, +})); + const workspace = workspace1.workspace; const MODELS_URL = `${PLATFORM_BASE_URL}${getModelsListModelsQueryKey(':workspace')[0]}`; const CREATE_AGENT_URL = `${PLATFORM_BASE_URL}${getAgentsListAgentsQueryKey(':workspace')[0]}`; diff --git a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx index be7f3ccaed..0a30e08bbd 100644 --- a/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx @@ -2,19 +2,28 @@ // SPDX-License-Identifier: Apache-2.0 import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; -import { Button, PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { Button, PageHeader, Stack, Text } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { AgentsTable, type AgentTableRow } from '@studio/components/dataViews/AgentsDataView'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + usePluginInstalled, + usePluginsError, + usePluginsLoaded, +} from '@studio/plugins/PluginContext'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { CreateDeploymentModal } from '@studio/routes/agents/AgentDeploymentsListRoute/CreateDeploymentModal'; import { CloneAgentModal } from '@studio/routes/agents/AgentsListRoute/CloneAgentModal'; import { CreateExampleAgentModal } from '@studio/routes/agents/AgentsListRoute/CreateExampleAgentModal'; import { getAgentDetailRoute } from '@studio/routes/utils'; +import { CircleAlert } from 'lucide-react'; import { type FC, useState } from 'react'; import { useNavigate } from 'react-router-dom'; export const AgentsListRoute: FC = () => { + const pluginsLoaded = usePluginsLoaded(); + const pluginsError = usePluginsError(); + const agentsInstalled = usePluginInstalled('agents'); const workspace = useWorkspaceFromPath(); const navigate = useNavigate(); const [createDeploymentAgent, setCreateDeploymentAgent] = useState(null); @@ -29,6 +38,24 @@ export const AgentsListRoute: FC = () => { const handleOpenDetails = (agent: AgentTableRow) => navigate(getAgentDetailRoute(workspace, agent.name)); + if (!pluginsLoaded && !pluginsError) { + return null; + } + + if (pluginsLoaded && !pluginsError && !agentsInstalled) { + return ( + + + Plugin Not Enabled + The Agents plugin is not installed. + + To use this page, the agents plugin must be registered with the platform. + Ask your administrator to install and enable the agents plugin. + + + ); + } + return ( diff --git a/web/packages/studio/src/routes/index.tsx b/web/packages/studio/src/routes/index.tsx index 139938df20..d8e1b56aaf 100644 --- a/web/packages/studio/src/routes/index.tsx +++ b/web/packages/studio/src/routes/index.tsx @@ -5,6 +5,8 @@ import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage'; import { ErrorPanel } from '@studio/components/ErrorPanel'; import { Loading } from '@studio/components/Layouts/Loading'; import { ROUTES } from '@studio/constants/routes'; +import { PluginProvider } from '@studio/plugins/PluginProvider'; +import { PluginRenderer } from '@studio/plugins/PluginRenderer'; import { agentRoutes, anonymizerRoutes, @@ -31,6 +33,7 @@ import { import { PageLayout } from '@studio/routes/PageLayout'; import { RootLayout } from '@studio/routes/RootLayout'; import { RootRedirect } from '@studio/routes/RootRedirect'; +import { gatePluginRoutes } from '@studio/routes/utils'; import { lazy, Suspense } from 'react'; import { Outlet } from 'react-router'; import type { RouteObject } from 'react-router-dom'; @@ -86,7 +89,11 @@ export const routes: RouteObject[] = [ }, { path: ROUTES.workspace.index, - element: } />, + element: ( + + } /> + + ), children: [ { path: ROUTES.workspace.index, @@ -119,6 +126,12 @@ export const routes: RouteObject[] = [ ...dataDesignerRoutes, ...anonymizerRoutes, ...agentRoutes, + ...gatePluginRoutes({ + // The /* suffix allows the plugin to own sub-paths via its own internal router. + path: `${ROUTES.workspace.plugin}/*`, + element: , + errorElement: , + }), ...settingsRoutes, ...modelCompareRoutes, ...memberRoutes, diff --git a/web/packages/studio/src/routes/utils.ts b/web/packages/studio/src/routes/utils.ts index 1dcc3f6221..7f3baa1467 100644 --- a/web/packages/studio/src/routes/utils.ts +++ b/web/packages/studio/src/routes/utils.ts @@ -23,6 +23,7 @@ import { MEMBERS_ENABLED, MODEL_COMPARE_ENABLED, OPTIMIZER_ENABLED, + PLUGINS_ENABLED, SAFE_SYNTHESIZER_ENABLED, SECRETS_ENABLED, SETTINGS_ENABLED, @@ -95,6 +96,9 @@ export const gateMembersRoutes = (routes: RouteObject | RouteObject[]) => export const agentsRoutes = (routes: RouteObject | RouteObject[]) => gateRoutes(AGENTS_ENABLED, routes); +export const gatePluginRoutes = (routes: RouteObject | RouteObject[]) => + gateRoutes(PLUGINS_ENABLED, routes); + export const gateCopilotStudioRoutes = (routes: RouteObject | RouteObject[]) => gateRoutes(COPILOT_STUDIO_ENABLED, routes); diff --git a/web/packages/studio/src/tests/title-change.test.tsx b/web/packages/studio/src/tests/title-change.test.tsx index 2f7693bcc7..3f06b0a818 100644 --- a/web/packages/studio/src/tests/title-change.test.tsx +++ b/web/packages/studio/src/tests/title-change.test.tsx @@ -40,6 +40,7 @@ const pathParams = { [RP.insightId]: 'test-insight', [RP.guardrailConfigName]: 'test-guardrail-config', [RP.guardrailChecksSubTab]: GuardrailChecksSubTab.Tests, + [RP.pluginName]: '', }; describe('AccessibleTitleE2E', () => { diff --git a/web/packages/studio/vite.config.ts b/web/packages/studio/vite.config.ts index f280de4fa1..246ae7a6c0 100644 --- a/web/packages/studio/vite.config.ts +++ b/web/packages/studio/vite.config.ts @@ -5,11 +5,13 @@ import { baseTestConfig } from '@nemo/testing/react/config'; import tailwindPostcss from '@tailwindcss/postcss'; import react from '@vitejs/plugin-react'; import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { build as rolldownBuild, type Plugin as RolldownPlugin } from 'rolldown'; import license, { type Dependency, type Person } from 'rollup-plugin-license'; import { visualizer } from 'rollup-plugin-visualizer'; -import { loadEnv } from 'vite'; +import { loadEnv, type Plugin } from 'vite'; import mkcert from 'vite-plugin-mkcert'; import svgr from 'vite-plugin-svgr'; // vite does not know about vitest -- vitest config extends vite config @@ -31,8 +33,285 @@ interface LicenseReportDependency { readonly licenseText: string | null; } +// Shared deps externalized from Studio's and plugins' production builds. At +// runtime, bare imports of these names resolve via an import map in index.html +// to bundles in public/vendor/, so Studio and every loaded plugin use one +// shared React/react-dom/router instance. +const VENDOR_EXTERNALS = [ + 'react', + 'react/jsx-runtime', + 'react-dom', + 'react-dom/client', + 'react-router', + 'react-router-dom', + // The design system is shared so plugins render KUI components against the + // same theme context Studio's KaizenThemeProvider populates (native look + + // dark mode) instead of bundling their own foundations copy. + '@nvidia/foundations-react-core', + // Shared so a plugin's useQuery reads Studio's QueryClientProvider — one + // query cache across Studio and every plugin. + '@tanstack/react-query', +] as const; + +// Each import specifier in the map resolves to a single vendor bundle. +// 'react/jsx-runtime' shares react.js and 'react-dom/client' shares +// react-dom.js so there's exactly one copy of each package's internals. +const VENDOR_IMPORT_MAP: Record = { + react: 'react.js', + 'react/jsx-runtime': 'react.js', + 'react-dom': 'react-dom.js', + 'react-dom/client': 'react-dom.js', + 'react-router': 'react-router.js', + 'react-router-dom': 'react-router-dom.js', + '@nvidia/foundations-react-core': 'foundations.js', + '@tanstack/react-query': 'react-query.js', +}; + +// Virtual modules have no filesystem location. Rolldown's resolver falls +// back to the build's cwd for bare specifiers, which is set to projectRoot +// below, so returning the virtual id as-is lets node-resolve find 'react' +// et al. in studio's node_modules. +const virtualShimPlugin = (shims: Record): RolldownPlugin => ({ + name: 'virtual-shim', + resolveId(id) { + return id in shims ? id : null; + }, + load(id) { + return shims[id] ?? null; + }, +}); + +// React's CJS uses `module.exports = require(...)` double-indirection that +// static CJS analysis can't split into named ESM exports, so `export *` +// only catches `default`. We introspect the real module at build time and +// emit explicit re-exports (`export var useState = _react["useState"]`) — +// rolldown treats those as static named exports. +function cjsNamedReexports(alias: string, keys: string[]): string[] { + return keys.map((k) => `export var ${k} = ${alias}["${k}"];`); +} + +// When a CJS dependency does `require("react")` and 'react' is external, +// rolldown leaves the call as `require(...)` guarded by +// `typeof require !== "undefined" ? require : `. In the browser +// no `require` exists, so the fallback throws. This banner declares a +// module-scope `require` backed by the ESM imports of the external names, +// so the guarded call sees a real function and returns the shared +// instance from the import map. +function buildRequireShim(externals: readonly string[]): string { + const imports = externals + .map((n, i) => `import * as __ext${i} from ${JSON.stringify(n)};`) + .join(' '); + const entries = externals.map((n, i) => `${JSON.stringify(n)}:__ext${i}`).join(','); + // Return the full ESM namespace, not `default`. The namespace carries + // both `default` and every named export (e.g. react-dom/client's + // createRoot / hydrateRoot), which CJS consumers expect to find directly + // on the value returned by require(). + return ( + `${imports} ` + + `var __externals = {${entries}}; ` + + 'var require = function(s) { ' + + 'var m = __externals[s]; if (m) return m; ' + + "throw new Error('Dynamic require of \"' + s + '\" is not supported'); " + + '};' + ); +} + +async function buildVendorBundles( + outdir: string, + projectRoot: string, + dev: boolean +): Promise { + // Load React modules from studio's node_modules and enumerate their real + // runtime exports so we can generate explicit named re-exports. + const requireFromStudio = createRequire(path.resolve(projectRoot, 'package.json')); + const keysOf = (spec: string): string[] => + Object.keys(requireFromStudio(spec)).filter((k) => k !== '__esModule' && k !== 'default'); + + const reactKeys = keysOf('react'); + // jsx-runtime's Fragment overlaps with react's; keep only unique keys. + const jsxRuntimeKeys = keysOf('react/jsx-runtime').filter((k) => !reactKeys.includes(k)); + // react-dom/client re-declares createRoot/hydrateRoot with + // `usingClientEntryPoint = true` to silence the legacy-root dev warning, + // so we export those *from* the client entry and skip them in react-dom's + // key list to avoid duplicate exports. + const reactDomClientKeys = keysOf('react-dom/client'); + const reactDomKeys = keysOf('react-dom').filter((k) => !reactDomClientKeys.includes(k)); + + const shims: Record = { + 'virtual:react': [ + "import _react from 'react';", + 'export default _react;', + ...cjsNamedReexports('_react', reactKeys), + "import _jsxRuntime from 'react/jsx-runtime';", + ...cjsNamedReexports('_jsxRuntime', jsxRuntimeKeys), + ].join('\n'), + 'virtual:react-dom': [ + "import _reactDom from 'react-dom';", + "import _reactDomClient from 'react-dom/client';", + // Both 'react-dom' and 'react-dom/client' import-map to this file, + // so the default export must satisfy both shapes: `import X from + // 'react-dom/client'` expects X.createRoot, while `import X from + // 'react-dom'` expects X.createPortal/flushSync/etc. Merging makes + // the same object usable from either import style. + 'var _reactDomMerged = Object.assign({}, _reactDom, _reactDomClient);', + 'export default _reactDomMerged;', + ...cjsNamedReexports('_reactDom', reactDomKeys), + ...cjsNamedReexports('_reactDomClient', reactDomClientKeys), + ].join('\n'), + 'virtual:react-router': "export * from 'react-router';", + 'virtual:react-router-dom': "export * from 'react-router-dom';", + // Foundations is ESM with static named exports, so a plain re-export works + // (no CJS named-reexport introspection needed as with react/react-dom). + 'virtual:foundations': "export * from '@nvidia/foundations-react-core';", + 'virtual:react-query': "export * from '@tanstack/react-query';", + }; + + const entries: Array<{ + entry: string; + outfile: string; + external: string[]; + banner?: string; + }> = [ + { entry: 'virtual:react', outfile: 'react.js', external: [] }, + { + entry: 'virtual:react-dom', + outfile: 'react-dom.js', + external: ['react'], + banner: buildRequireShim(['react']), + }, + { entry: 'virtual:react-router', outfile: 'react-router.js', external: ['react'] }, + { + entry: 'virtual:react-router-dom', + outfile: 'react-router-dom.js', + external: ['react', 'react-dom', 'react-router'], + }, + { + entry: 'virtual:foundations', + outfile: 'foundations.js', + external: ['react', 'react-dom'], + // Bundled CJS deps inside foundations may `require('react')` / + // `require('react-dom')`; the shim routes those to the shared copies. + banner: buildRequireShim(['react', 'react-dom']), + }, + { + entry: 'virtual:react-query', + outfile: 'react-query.js', + external: ['react'], + banner: buildRequireShim(['react']), + }, + ]; + + fs.rmSync(outdir, { recursive: true, force: true }); + fs.mkdirSync(outdir, { recursive: true }); + await Promise.all( + entries.map(({ entry, outfile, external, banner }) => + rolldownBuild({ + input: entry, + cwd: projectRoot, + platform: 'browser', + // Dev needs React's development build: Fast Refresh's scheduleRefresh + // hook and dev warnings only exist in the development renderer. + transform: { + define: { 'process.env.NODE_ENV': dev ? '"development"' : '"production"' }, + }, + external, + plugins: [virtualShimPlugin(shims)], + output: { + file: path.resolve(outdir, outfile), + format: 'esm', + // Single-file output per vendor bundle so each maps to one import-map + // entry. Required for foundations, whose internal dynamic imports + // would otherwise split into multiple chunks (rejected by output.file). + codeSplitting: false, + minify: !dev, + sourcemap: true, + banner, + }, + }) + ) + ); +} + +// Generates public/vendor/*.js and injects an inline +//