feat(studio): plugin system — web UI, nav, and /apis/plugins discovery#594
feat(studio): plugin system — web UI, nav, and /apis/plugins discovery#594marcusds wants to merge 11 commits into
Conversation
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a ChangesPlugin contract, example plugin, and backend discovery
Studio frontend plugin loading, rendering, and navigation
Sequence Diagram(s)sequenceDiagram
participant PluginProvider
participant StudioAPI
participant PluginBundle
PluginProvider->>StudioAPI: fetch /apis/plugins
StudioAPI-->>PluginProvider: manifests[]
PluginProvider->>PluginProvider: validate manifest, check isTrustedBundleUrl
PluginProvider->>PluginBundle: import(bundleUrl)
PluginBundle-->>PluginProvider: {mount, navItems}
PluginProvider->>PluginProvider: set plugins, installedNames, isLoaded
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx (1)
26-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winFull page renders before plugin state resolves.
Guard only triggers once
pluginsLoadedis true; until then,AgentsTableand its data fetch render regardless of whether the agents plugin will end up installed. Consider showing a loading/neutral state while!pluginsLoaded, then the notice or full page once resolved, to avoid the flicker and unnecessary fetch.💡 Possible fix
- if (pluginsLoaded && !agentsInstalled) { + if (!pluginsLoaded) { + return null; // or a loading placeholder + } + + if (!agentsInstalled) { return (🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx` around lines 26 - 66, The Agents list page currently renders the main content before plugin status is known, causing a flicker and unnecessary data loading. Update AgentsListRoute to treat !pluginsLoaded as a separate loading/neutral state, and only render the AgentsTable or the “Plugin Not Enabled” notice after the plugin state is resolved. Use the existing pluginsLoaded, agentsInstalled, and AgentsListRoute logic to gate rendering cleanly.
🧹 Nitpick comments (1)
web/packages/studio/src/plugins/PluginContext.test.tsx (1)
89-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMisleading duplicate test.
Title claims to cover the "missing-export" path, but the body is identical to the untrusted-URL test above (lines 24-41). Adds no new coverage and misleads readers about what's tested. Either implement the missing-export scenario (mock dynamic
import()) or drop this test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/packages/studio/src/plugins/PluginContext.test.tsx` around lines 89 - 109, This test is misleading because it claims to cover the missing-export path but actually duplicates the untrusted bundle URL case and adds no new coverage. Update the test in PluginContext.test.tsx by either removing it or changing it to exercise the loadPlugin() missing-export branch with a mocked dynamic import that returns a bundle without mount/navItems, so the assertion matches the scenario being tested.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@services/studio/src/nmp/studio/plugins.py`:
- Around line 54-95: The validation in _validate_bundle_path only checks that
bundle_path resolves to a regular file and stays within the plugin root, but it
never enforces the filename expected by bundle_url in the plugin registration
flow. Add an explicit check in _validate_bundle_path that bundle_path.name
matches the hardcoded UI entry filename used by Studio plugin registration (the
index.js path built in the plugin setup), and log a warning then return False
when it does not, so misconfigured StudioSpec.bundle_path values are rejected
during discovery instead of causing runtime 404s.
In `@web/packages/studio/src/plugins/PluginContext.tsx`:
- Around line 48-54: The dynamic import in PluginContext’s plugin loader is
using an unsafe any cast, which bypasses type checking. Change the imported
module from any to unknown, then add a narrow type guard before accessing
module.mount and module.navItems, following the same pattern used by
isValidPluginManifest. Keep the existing validation and warning behavior, but
only return the plugin object after the guard confirms both exports are
functions.
In `@web/packages/studio/src/plugins/PluginRenderer.tsx`:
- Around line 19-27: The PluginRenderer useEffect currently calls plugin.mount
directly, so a synchronous throw from third-party plugin code can escape and
affect the app. Update the mount path in PluginRenderer to wrap
plugin.mount(containerRef.current, ...) in error handling, catching synchronous
failures and preventing them from propagating; keep the existing cleanup return
behavior for successful mounts.
---
Outside diff comments:
In `@web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx`:
- Around line 26-66: The Agents list page currently renders the main content
before plugin status is known, causing a flicker and unnecessary data loading.
Update AgentsListRoute to treat !pluginsLoaded as a separate loading/neutral
state, and only render the AgentsTable or the “Plugin Not Enabled” notice after
the plugin state is resolved. Use the existing pluginsLoaded, agentsInstalled,
and AgentsListRoute logic to gate rendering cleanly.
---
Nitpick comments:
In `@web/packages/studio/src/plugins/PluginContext.test.tsx`:
- Around line 89-109: This test is misleading because it claims to cover the
missing-export path but actually duplicates the untrusted bundle URL case and
adds no new coverage. Update the test in PluginContext.test.tsx by either
removing it or changing it to exercise the loadPlugin() missing-export branch
with a mocked dynamic import that returns a bundle without mount/navItems, so
the assertion matches the scenario being tested.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: bc5ab84a-50cf-4f3a-88db-95f982a39d73
⛔ Files ignored due to path filters (3)
plugins/example-plugin/src/nemo_example_plugin/web/dist/index.jsis excluded by!**/dist/**plugins/example-plugin/web/package-lock.jsonis excluded by!**/package-lock.jsonweb/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (39)
packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.pypackages/nemo_platform_plugin/tests/test_interface.pyplugins/example-plugin/pyproject.tomlplugins/example-plugin/src/nemo_example_plugin/studio.pyplugins/example-plugin/tests/test_studio.pyplugins/example-plugin/web/.gitignoreplugins/example-plugin/web/package.jsonplugins/example-plugin/web/src/App.tsxplugins/example-plugin/web/src/Nav.tsxplugins/example-plugin/web/src/index.tsplugins/example-plugin/web/src/mount.tsxplugins/example-plugin/web/src/types.tsplugins/example-plugin/web/tsconfig.jsonplugins/example-plugin/web/vite.config.tsservices/studio/src/nmp/studio/config.pyservices/studio/src/nmp/studio/plugins.pyservices/studio/src/nmp/studio/service.pyservices/studio/src/nmp/studio/static_files.pyservices/studio/tests/unit/test_plugins.pyservices/studio/tests/unit/test_service.pyservices/studio/tests/unit/test_static_files.pyweb/package.jsonweb/packages/studio/.gitignoreweb/packages/studio/package.jsonweb/packages/studio/src/constants/routes.tsweb/packages/studio/src/plugins/PluginContext.test.tsxweb/packages/studio/src/plugins/PluginContext.tsxweb/packages/studio/src/plugins/PluginRenderer.test.tsxweb/packages/studio/src/plugins/PluginRenderer.tsxweb/packages/studio/src/plugins/iconMap.test.tsweb/packages/studio/src/plugins/iconMap.tsweb/packages/studio/src/plugins/security.test.tsweb/packages/studio/src/plugins/security.tsweb/packages/studio/src/plugins/types.tsweb/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsxweb/packages/studio/src/routes/agents/AgentsListRoute/index.tsxweb/packages/studio/src/routes/index.tsxweb/packages/studio/src/tests/title-change.test.tsxweb/packages/studio/vite.config.ts
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
services/studio/src/nmp/studio/plugins.py (1)
87-106: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winContainment check fails open on exception.
If
dist.locate_file(...)/_editable_source_rootraise (malformed metadata, unexpectedimportlib.metadatabehavior, etc.), theexceptbranch only logs at debug level and falls through toreturn True— the bundle is served without ever confirming containment. This defeats the intended "prevent path-traversal" guarantee for any plugin whose distribution metadata can't be parsed cleanly, rather than only for the documented "metadata unavailable (dist is None)" case.🔒 Proposed fix — fail closed on error
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) + logger.warning( + "Could not verify distribution root for plugin %r — skipping bundle", ep_name, exc_info=True + ) + return False🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/studio/src/nmp/studio/plugins.py` around lines 87 - 106, The containment check in the plugin bundle path validation should fail closed when distribution metadata lookup or root resolution errors occur. In the plugin discovery flow around discover_entry_points, _editable_source_root, and dist.locate_file, change the exception path so it does not fall through to the final True return; instead log the failure and return False so bundles are only served when containment is actually confirmed.
🧹 Nitpick comments (2)
web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx (1)
311-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePlugin groups aren't merged into existing groups of the same name.
A plugin declaring a group label matching a built-in group (e.g.
"Jobs") renders as a duplicate section rather than merging. Low risk today since onlyexample-pluginexists, but consider merging by group name as more plugins are added.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx` around lines 311 - 341, The plugin nav handling in WorkspaceSideNav currently appends each plugin’s navItems output as separate sections, so matching group labels are duplicated instead of merged. Update the pluginNavGroups logic (or the data passed into NavigationDrawer) to merge plugin items into existing groups by group name, reusing the same group key for built-in and plugin sections and combining their items before rendering. Make the change around plugin.navItems(workspace) and the items prop passed to NavigationDrawer so groups like "Jobs" collapse into one section.web/packages/studio/src/plugins/PluginContext.tsx (1)
116-122: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winDefault retry delays fail-open error state.
fetchPluginsintentionally throws to "fail open like a network error" on bad data, butuseQuerykeeps the defaultretry: 3(exponential backoff). On a genuine outage,isError(and thusisLoaded) won't flip for several seconds while retries run, prolonging the loading state longer than the fail-open design intends.♻️ Proposed fix
const { data, isSuccess, isError } = useQuery({ queryKey: ['plugins', 'manifest'], queryFn: fetchPlugins, staleTime: Infinity, gcTime: Infinity, refetchOnReconnect: false, + retry: false, });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/packages/studio/src/plugins/PluginContext.tsx` around lines 116 - 122, The PluginContext useQuery call is relying on the default retry behavior, which delays the fail-open error state after fetchPlugins throws on bad data or outage. Update the useQuery options in PluginContext to disable retries or set retry behavior so isError can flip immediately and isLoaded resolves promptly. Keep the change localized to the query setup that uses fetchPlugins and the ['plugins', 'manifest'] queryKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@services/studio/src/nmp/studio/plugins.py`:
- Around line 87-106: The containment check in the plugin bundle path validation
should fail closed when distribution metadata lookup or root resolution errors
occur. In the plugin discovery flow around discover_entry_points,
_editable_source_root, and dist.locate_file, change the exception path so it
does not fall through to the final True return; instead log the failure and
return False so bundles are only served when containment is actually confirmed.
---
Nitpick comments:
In `@web/packages/studio/src/plugins/PluginContext.tsx`:
- Around line 116-122: The PluginContext useQuery call is relying on the default
retry behavior, which delays the fail-open error state after fetchPlugins throws
on bad data or outage. Update the useQuery options in PluginContext to disable
retries or set retry behavior so isError can flip immediately and isLoaded
resolves promptly. Keep the change localized to the query setup that uses
fetchPlugins and the ['plugins', 'manifest'] queryKey.
In `@web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx`:
- Around line 311-341: The plugin nav handling in WorkspaceSideNav currently
appends each plugin’s navItems output as separate sections, so matching group
labels are duplicated instead of merged. Update the pluginNavGroups logic (or
the data passed into NavigationDrawer) to merge plugin items into existing
groups by group name, reusing the same group key for built-in and plugin
sections and combining their items before rendering. Make the change around
plugin.navItems(workspace) and the items prop passed to NavigationDrawer so
groups like "Jobs" collapse into one section.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 0d618385-393b-4b58-8e4b-8f7913d0507c
📒 Files selected for processing (23)
packages/nmp_common/src/nmp/common/auth/middleware.pypackages/nmp_common/tests/auth/test_middleware.pyplugins/example-plugin/web/src/App.tsxplugins/example-plugin/web/src/mount.tsxplugins/example-plugin/web/src/types.tsservices/studio/src/nmp/studio/plugins.pyservices/studio/src/nmp/studio/service.pyservices/studio/src/nmp/studio/static_files.pyservices/studio/src/nmp/studio/studio_links.pyservices/studio/tests/unit/test_coding_agents.pyservices/studio/tests/unit/test_plugins.pyservices/studio/tests/unit/test_service.pyservices/studio/tests/unit/test_static_files.pyweb/packages/studio/src/plugins/PluginContext.test.tsxweb/packages/studio/src/plugins/PluginContext.tsxweb/packages/studio/src/plugins/PluginRenderer.test.tsxweb/packages/studio/src/plugins/PluginRenderer.tsxweb/packages/studio/src/plugins/iconMap.test.tsweb/packages/studio/src/plugins/iconMap.tsweb/packages/studio/src/plugins/types.tsweb/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsxweb/packages/studio/src/routes/agents/AgentsListRoute/index.tsxweb/packages/studio/vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (11)
- web/packages/studio/src/plugins/iconMap.ts
- web/packages/studio/src/plugins/iconMap.test.ts
- plugins/example-plugin/web/src/types.ts
- web/packages/studio/src/plugins/types.ts
- web/packages/studio/src/plugins/PluginRenderer.test.tsx
- plugins/example-plugin/web/src/mount.tsx
- web/packages/studio/src/plugins/PluginContext.test.tsx
- web/packages/studio/src/routes/agents/AgentsListRoute/index.tsx
- web/packages/studio/vite.config.ts
- web/packages/studio/src/plugins/PluginRenderer.tsx
- services/studio/src/nmp/studio/static_files.py
…iscovery
Port the studio-plugin-system feature onto main. Adds:
- Plugin discovery via the nemo.studio entry-point group and a StudioSpec
type in nemo_platform_plugin; GET /apis/plugins returns installed plugins
and their bundle URLs.
- Studio runtime: PluginContext loads bundles via dynamic import(),
PluginRenderer mounts them at /workspaces/:id/plugin/:pluginName/*, and the
side nav renders plugin-contributed nav groups.
- React vendor-sharing: Studio and every plugin bundle resolve react /
react-dom / react-router through an import map to shared /vendor bundles
built by a rolldown vite plugin, so hooks work across the boundary.
- Agents route and side-nav visibility gate on usePluginInstalled('agents').
- example-plugin: reference plugin exercising the Python + web contract.
Signed-off-by: mschwab <mschwab@nvidia.com>
… fetch - Serve plugin bundles via an allowlisted FileResponse route (.js/.js.map/.css, direct children only) with symlink-escape containment, replacing the per-plugin StaticFiles mount. - Bypass auth for GET /apis/plugins and /plugin-ui/ so the SPA can fetch the manifest pre-login and load bundles via dynamic import(). - Build CSP from configured cross-origin endpoints (OIDC issuer, platform base URL, microservice URLs) instead of a fixed same-origin policy. - Drive the plugin manifest with react-query; expose isError and fail open (show agents nav/routes) when the manifest can't be trusted, including malformed non-array responses. - Source studio plugins from discover_manifests(); require bundle index.js. - Keep plugins mounted across OIDC silent renew via a getAccessToken() accessor; migrate the example plugin to it. - lucide: look up icons via the icons record to exclude non-icon exports. - vite: serve the React dev build in dev, minified prod build in build. Signed-off-by: mschwab <mschwab@nvidia.com>
- PluginRenderer: guard third-party plugin.mount() with try/catch so a synchronous throw logs and returns instead of crashing the app - AgentsListRoute: render a neutral state while plugin state is pending to avoid flicker and an unnecessary agents fetch; preserves fail-open on error - CSP tests: check directive token membership (split) instead of substring containment (CodeQL incomplete URL substring sanitization, 4238-4241) - Drop misleading duplicate PluginContext test that re-ran the untrusted-URL case under a missing-export title Signed-off-by: mschwab <mschwab@nvidia.com>
The .split() token-membership check still matched CodeQL's incomplete URL substring sanitization query (it keys on a URL-literal on the left of an `in` comparison regardless of the right operand's type). Assert the full directive string instead — removes the `in` pattern entirely and is a stronger check. Covers alerts 4238-4241. Signed-off-by: mschwab <mschwab@nvidia.com>
lint-copyright-headers CI check flagged 8 plugin files missing the SPDX header. Added via script/copyright_fixer.py. Signed-off-by: mschwab <mschwab@nvidia.com>
The routes/index safety-net test asserts every workspace route except the default is behind a feature flag; the plugin route was added ungated, failing Web tests. Add a pluginsEnabled flag (VITE_FF_PLUGINS_ENABLED, default on) wired end-to-end — flagDefinitions, environment constant, env_mappings marker, .env.fastapi parity, dev sample — and gate the route via gatePluginRoutes. Signed-off-by: mschwab <mschwab@nvidia.com>
…n system Plugins previously mounted into a detached React root (createRoot) and stood up their own BrowserRouter, isolating them from Studio's contexts. Collapse that into Studio's tree: a plugin now exports a `Root` component that Studio renders under its own Router / QueryClient / theme providers, so it shares the router (no BrowserRouter, no history-patch) and the design system while still shipping as a separately-built bundle with its own private deps. - Contract: `mount(container, props)` -> `Root` component + `navItems`; drop `basename` (Studio's router applies it centrally). Consolidate PluginModule, PluginQueryData, PluginContextValue, PluginProviderProps into types.ts. - PluginRenderer renders `<Root workspaceId auth/>` in-tree; crash isolation comes from the plugin route's existing errorElement. Auth stays a prop by design (no refresh token over the boundary). - Share `@nvidia/foundations-react-core` as an import-map singleton (vendor bundle) so plugin KUI renders against Studio's single foundations instance and theme. codeSplitting:false on vendor output — foundations' internal dynamic imports would otherwise split into multiple chunks. - Reskin example plugin with KUI (Text/Stack/Flex) + theme-aware surface tokens, replacing hardcoded Tailwind; rebuild bundle. Signed-off-by: mschwab <mschwab@nvidia.com>
Add always-on guidance for building a Studio plugin's web UI: plugins render inside Studio's React tree sharing React, router, and the KUI design system. Co-locate the contract + DO/DON'T rules in the example plugin (the canonical template) and point to it from the root AGENTS.md so new plugin authors find it. Signed-off-by: mschwab <mschwab@nvidia.com>
Add @tanstack/react-query to the import-map singleton set so a plugin's useQuery reads Studio's QueryClientProvider — one query cache across Studio and every plugin. Demonstrate on the example plugin's Overview page with a useQuery against the real /apis/plugins endpoint; update the plugin web AGENTS.md. Signed-off-by: mschwab <mschwab@nvidia.com>
…nsts Move the pure manifest logic (fetchPlugins, loadPlugin, validators) into utils.ts and the constants (endpoint, query key, empty defaults) into consts.ts. Separate the PluginProvider component from the context object and hooks (PluginContext is now a .ts module) so hooks no longer share a file with a component — dropping the per-export react-refresh/only-export-components disables. Signed-off-by: mschwab <mschwab@nvidia.com>
dccc87d to
394aa58
Compare
The built web/dist/index.js is minified (source comments stripped), so it had no SPDX header — CI's copyright-header check (script/copyright_fixer.py) requires the header literally in the file, unlike REUSE.toml's catch-all. Add the header back via a rolldown output banner so every rebuild keeps it. Signed-off-by: mschwab <mschwab@nvidia.com>
Summary
Adds a Studio plugin web-UI system: NeMo Platform plugins contribute a web experience that Studio discovers at runtime and renders inside its own React tree, sharing Studio's React, router, KUI design system, and query client — so plugin pages look and behave native — while each plugin still ships as a separately-built bundle with its own private deps.
Ported onto current
main. Verified:ruff/ty/eslintclean, frontend typecheck, backend + frontend plugin suites green, and a productionvite buildthat emits the shared vendor bundles + import map + license report.How it fits together
1. The plugin contract
A plugin bundle exports a React component + nav:
Rendered inside Studio's tree
PluginRendererrenders<Root workspaceId auth/>as a child of Studio's providers (Router, QueryClient, KaizenThemeProvider) — nocreateRoot, noBrowserRouter, no history patching. Consequences:<Routes>, relative paths); Studio applies the basename centrally.useQueryreads Studio'sQueryClientProvider: one cache across Studio and every plugin.@nvidia/foundations-react-core) + Studio's semantic token classes (bg-surface-*,text-subtle, …), so plugins inherit theme + dark mode.getAccessToken(), access token only). Never a refresh token, never Studio's OIDC context.errorElement.Shared singletons (import map)
For a plugin to consume Studio's contexts, the context-bearing libraries must be a single instance.
web/packages/studio/vite.config.tsexternalizes and vendor-bundles react, react-dom, react-router(-dom),@nvidia/foundations-react-core,@tanstack/react-query, resolved at runtime via an inline<script type="importmap">. Each vendor bundle is single-file (codeSplitting: false— foundations' internal dynamic imports would otherwise split). Everything else stays private to the plugin's own bundle.navItems(workspaceId) → PluginNavGroup[]Plugins contribute side-nav entries; icons are kebab-case Lucide names resolved by
iconMap.ts.WorkspaceSideNavcallsnavItems(workspace)for each loaded plugin and appends the groups after the built-in nav.Runtime loading
PluginContextfetchesGET /apis/plugins(via TanStack Query), dynamic-import()s eachbundleUrl, and validates theRoot+navItemsexports (untrusted URLs / missing exports are rejected and logged). The plugins module is split for clarity:PluginContext.ts(context + hooks),PluginProvider.tsx(provider),utils.ts(fetch/load/validate),consts.ts(endpoint, query key, defaults).2. Backend
At startup
services/studio/src/nmp/studio/plugins.pyrunsdiscover_plugins(), which:nemo.studiofactory and validates the returnedStudioSpec,/plugin-ui/<name>/index.js) — never fromspec.name/spec.bundle_path, to prevent bundle-slot hijacking / path traversal (_validate_bundle_path),GET /apis/plugins.A plugin with no web UI still appears with
bundleUrl: null(headless).3. Plugin-gated surfaces (Agents)
PluginContextexposesinstalledNamesviausePluginInstalled(name). The Agents side-nav group and route are gated onusePluginInstalled('agents')— nav hidden when absent, route shows a "Plugin Not Enabled" notice on direct navigation. This is the intended pattern for any plugin-gated surface.Notes for reviewers
Rootcomponent (rendered in Studio's tree) rather than amount(container, props)that stands up its own React root. Removed the per-pluginBrowserRouter+history.pushStatepatch.@nvidia/foundations-react-core) and@tanstack/react-query, so plugins get native look + a shared query cache.codeSplitting: falseon the vendor build is required for foundations.react-refresh/only-export-componentsdisables.AGENTS.mdpoints toplugins/example-plugin/web/AGENTS.md, which holds the web-UI contract (DO/DON'T rules) and is the canonical template to copy.StudioSpeclives innemo_platform_plugin.interface; new deprolldownin the studio package for the vendor-bundle build (lockfile updated).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation