From 4d7b4b70e3e1b2ed33f079277548335fca1282a2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 31 May 2026 10:00:24 -0700 Subject: [PATCH 1/9] feat(plugins): Add unified plugin registrations Let apps define one plugin set and pass it to both Nitro and createApp. Manifest-only plugins use package-name strings, while trusted plugins carry inline manifests and hooks from app code. Remove split YAML registration for trusted hooks and move GitHub and Scheduler to JavaScript plugin definitions. This keeps trusted runtime behavior explicit without forcing manifest-only packages to write unused code. Fixes GH-453 Co-Authored-By: GPT-5 Codex --- apps/example/README.md | 8 +- apps/example/nitro.config.ts | 6 +- apps/example/plugin-packages.ts | 10 - apps/example/plugins.ts | 22 +++ apps/example/server.ts | 10 +- .../content/docs/extend/_plugin-template.md | 15 +- .../docs/extend/agent-browser-plugin.md | 15 +- .../src/content/docs/extend/build-a-plugin.md | 86 ++++---- .../src/content/docs/extend/datadog-plugin.md | 15 +- .../src/content/docs/extend/github-plugin.md | 37 ++-- .../src/content/docs/extend/hex-plugin.md | 15 +- .../docs/src/content/docs/extend/index.md | 134 +++++++------ .../src/content/docs/extend/linear-plugin.md | 15 +- .../src/content/docs/extend/notion-plugin.md | 15 +- .../content/docs/extend/scheduler-plugin.md | 24 +-- .../src/content/docs/extend/sentry-plugin.md | 15 +- .../src/content/docs/reference/api/README.md | 7 + .../docs/reference/api/functions/createApp.md | 2 +- .../api/functions/defineJuniorPlugins.md | 26 +++ .../reference/api/functions/juniorNitro.md | 2 +- .../api/interfaces/JuniorAppOptions.md | 16 +- .../api/interfaces/JuniorNitroOptions.md | 14 +- .../api/interfaces/JuniorPluginSet.md | 40 ++++ .../api/interfaces/JuniorPluginSetOptions.md | 18 ++ .../api/type-aliases/JuniorPluginInput.md | 10 + .../content/docs/start-here/existing-app.md | 23 ++- .../src/content/docs/start-here/quickstart.md | 57 ++++-- packages/junior-agent-browser/README.md | 9 + packages/junior-datadog/README.md | 15 +- .../junior-evals/evals/behavior-harness.ts | 13 +- packages/junior-github/README.md | 22 +-- packages/junior-github/index.d.ts | 8 +- packages/junior-github/index.js | 39 +++- packages/junior-github/package.json | 1 - packages/junior-github/plugin.yaml | 38 ---- packages/junior-hex/README.md | 11 +- packages/junior-linear/README.md | 15 +- packages/junior-notion/README.md | 15 +- packages/junior-plugin-api/src/index.ts | 153 +++++++++++++- packages/junior-scheduler/package.json | 3 +- packages/junior-scheduler/plugin.yaml | 2 - packages/junior-scheduler/src/plugin.ts | 8 +- packages/junior-sentry/README.md | 9 + packages/junior/README.md | 5 +- .../skills/junior/references/examples.md | 2 +- .../skills/junior/references/packaging.md | 53 +++-- .../validation-and-troubleshooting.md | 32 +-- packages/junior/src/api-reference.ts | 6 + packages/junior/src/app.ts | 186 +++++++++++------- packages/junior/src/build/virtual-config.ts | 13 +- .../src/chat/agent-dispatch/heartbeat.ts | 2 +- .../junior/src/chat/plugins/agent-hooks.ts | 20 +- packages/junior/src/chat/plugins/manifest.ts | 6 +- .../src/chat/plugins/package-discovery.ts | 10 + packages/junior/src/chat/plugins/registry.ts | 91 +++++++-- packages/junior/src/chat/plugins/types.ts | 10 +- packages/junior/src/cli/check.ts | 8 +- packages/junior/src/nitro.ts | 24 ++- packages/junior/src/plugins.ts | 162 +++++++++++++++ packages/junior/src/virtual-modules.d.ts | 5 +- .../tests/integration/heartbeat.test.ts | 5 +- packages/junior/tests/unit/app-config.test.ts | 115 ++++++----- .../junior/tests/unit/cli/check-cli.test.ts | 40 +++- .../tests/unit/plugins/agent-hooks.test.ts | 20 +- .../plugin-manifest-api-headers.test.ts | 16 +- .../plugins/plugin-registry-packages.test.ts | 12 +- .../unit/plugins/plugin-registry.test.ts | 6 + .../tests/unit/skills-plugin-provider.test.ts | 1 + .../tests/unit/tools/load-skill.test.ts | 2 + specs/plugin-manifest.md | 2 +- specs/plugin-runtime.md | 19 +- specs/plugin.md | 7 +- 72 files changed, 1266 insertions(+), 632 deletions(-) delete mode 100644 apps/example/plugin-packages.ts create mode 100644 apps/example/plugins.ts create mode 100644 packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md create mode 100644 packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md create mode 100644 packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md create mode 100644 packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md delete mode 100644 packages/junior-github/plugin.yaml delete mode 100644 packages/junior-scheduler/plugin.yaml create mode 100644 packages/junior/src/plugins.ts diff --git a/apps/example/README.md b/apps/example/README.md index b0fbe769d..9a64ddd97 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -7,7 +7,7 @@ It demonstrates: - one local skill (`/example-local`) - one plugin-bundled skill (`/example-bundle-help`) - one bundle-only plugin (`app/plugins/example-bundle/plugin.yaml`) with no credential broker config -- installed plugin packages (`@sentry/junior-agent-browser`, `@sentry/junior-github`, `@sentry/junior-hex`, `@sentry/junior-linear`, `@sentry/junior-notion`, `@sentry/junior-sentry`, `@sentry/junior-vercel`) +- installed plugin packages (`@sentry/junior-agent-browser`, `@sentry/junior-datadog`, `@sentry/junior-github`, `@sentry/junior-hex`, `@sentry/junior-linear`, `@sentry/junior-notion`, `@sentry/junior-sentry`, `@sentry/junior-vercel`) ## Run @@ -38,7 +38,7 @@ Copy `.env.example` and set: ## Wiring -- `plugin-packages.ts` is the single source of truth for installed plugin packages in this app -- `nitro.config.ts` passes that list to `juniorNitro()` so plugin content is copied into the build output -- `server.ts` registers trusted runtime plugins, including the dashboard plugin, through `createApp({ plugins: [...] })` +- `plugins.ts` is the single source of truth for installed plugin registrations and trusted runtime plugins in this app +- `nitro.config.ts` passes that set to `juniorNitro()` so plugin content is copied into the build output +- `server.ts` passes the same set to `createApp()` so local dev and deployed builds use one plugin contract - root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used for trusted plugin heartbeats and stale dispatch recovery; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover dispatched runs diff --git a/apps/example/nitro.config.ts b/apps/example/nitro.config.ts index 4352440bb..20a68abce 100644 --- a/apps/example/nitro.config.ts +++ b/apps/example/nitro.config.ts @@ -1,14 +1,12 @@ import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { examplePluginPackages } from "./plugin-packages"; +import { examplePlugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: examplePluginPackages, - }, + plugins: examplePlugins, }), ], routes: { diff --git a/apps/example/plugin-packages.ts b/apps/example/plugin-packages.ts deleted file mode 100644 index adbde54c3..000000000 --- a/apps/example/plugin-packages.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const examplePluginPackages = [ - "@sentry/junior-agent-browser", - "@sentry/junior-datadog", - "@sentry/junior-github", - "@sentry/junior-hex", - "@sentry/junior-linear", - "@sentry/junior-notion", - "@sentry/junior-sentry", - "@sentry/junior-vercel", -]; diff --git a/apps/example/plugins.ts b/apps/example/plugins.ts new file mode 100644 index 000000000..6fe63e438 --- /dev/null +++ b/apps/example/plugins.ts @@ -0,0 +1,22 @@ +import { defineJuniorPlugins } from "@sentry/junior"; +import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; +import { githubPlugin } from "@sentry/junior-github"; +import { exampleDashboardAuthRequired } from "./dashboard"; + +export const examplePlugins = defineJuniorPlugins([ + juniorDashboardPlugin({ + authRequired: exampleDashboardAuthRequired(), + allowedGoogleDomains: ["sentry.io"], + }), + "@sentry/junior-agent-browser", + "@sentry/junior-datadog", + githubPlugin({ + botNameEnv: "GITHUB_APP_BOT_NAME", + botEmailEnv: "GITHUB_APP_BOT_EMAIL", + }), + "@sentry/junior-hex", + "@sentry/junior-linear", + "@sentry/junior-notion", + "@sentry/junior-sentry", + "@sentry/junior-vercel", +]); diff --git a/apps/example/server.ts b/apps/example/server.ts index 20f24ca9b..c03519988 100644 --- a/apps/example/server.ts +++ b/apps/example/server.ts @@ -1,17 +1,11 @@ import { createApp } from "@sentry/junior"; -import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; import { initSentry } from "@sentry/junior/instrumentation"; -import { exampleDashboardAuthRequired } from "./dashboard"; +import { examplePlugins } from "./plugins"; initSentry(); const app = await createApp({ - plugins: [ - juniorDashboardPlugin({ - authRequired: exampleDashboardAuthRequired(), - allowedGoogleDomains: ["sentry.io"], - }), - ], + plugins: examplePlugins, configDefaults: { "sentry.org": "sentry", }, diff --git a/packages/docs/src/content/docs/extend/_plugin-template.md b/packages/docs/src/content/docs/extend/_plugin-template.md index 8ee4c7f83..0cab90715 100644 --- a/packages/docs/src/content/docs/extend/_plugin-template.md +++ b/packages/docs/src/content/docs/extend/_plugin-template.md @@ -21,14 +21,13 @@ pnpm add @sentry/junior @sentry/junior-example ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-example"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-example"]); ``` ## Configure environment variables diff --git a/packages/docs/src/content/docs/extend/agent-browser-plugin.md b/packages/docs/src/content/docs/extend/agent-browser-plugin.md index 46c822ca4..88cd903d1 100644 --- a/packages/docs/src/content/docs/extend/agent-browser-plugin.md +++ b/packages/docs/src/content/docs/extend/agent-browser-plugin.md @@ -22,14 +22,13 @@ pnpm add @sentry/junior @sentry/junior-agent-browser ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-agent-browser"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-agent-browser"]); ``` ## Configure environment variables diff --git a/packages/docs/src/content/docs/extend/build-a-plugin.md b/packages/docs/src/content/docs/extend/build-a-plugin.md index ed98f03f9..d7aa8a885 100644 --- a/packages/docs/src/content/docs/extend/build-a-plugin.md +++ b/packages/docs/src/content/docs/extend/build-a-plugin.md @@ -17,7 +17,7 @@ Use local `app/plugins` while iterating in one app. Publish an npm package when ## Package layout -Use the same shape locally and in packages: +Manifest-only plugins use a data-only package: ```text title="Plugin package" my-junior-plugin/ @@ -38,25 +38,8 @@ The package must include the manifest and skills in `package.json`: } ``` -If the package also exports trusted runtime hooks, include the entrypoint and -depend on `@sentry/junior-plugin-api`: - -```json title="package.json" -{ - "name": "@acme/junior-my-provider", - "type": "module", - "exports": { - ".": { - "types": "./index.d.ts", - "default": "./index.js" - } - }, - "files": ["index.d.ts", "index.js", "plugin.yaml", "skills"], - "dependencies": { - "@sentry/junior-plugin-api": "^0.53.0" - } -} -``` +Use a JavaScript plugin factory instead of `plugin.yaml` when the package needs +trusted runtime hooks. ## Minimal manifest @@ -117,32 +100,26 @@ Junior merges runtime dependency declarations from all loaded plugins and prepar ## Register the package -Install the plugin next to `@sentry/junior`, then list it in `juniorNitro`: +Install the plugin next to `@sentry/junior`, then add the package name to a +shared plugin set: -```ts title="nitro.config.ts" -import { defineConfig } from "nitro"; -import { juniorNitro } from "@sentry/junior/nitro"; +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; -export default defineConfig({ - modules: [ - juniorNitro({ - plugins: { - packages: ["@acme/junior-my-provider"], - }, - }), - ], -}); +export const plugins = defineJuniorPlugins(["@acme/junior-my-provider"]); ``` -Do not use the removed `pluginPackages` option. `junior check` rejects it. +Pass the same `plugins` value to `juniorNitro({ plugins })` and +`createApp({ plugins })`. Do not use the removed `pluginPackages` or +`plugins.packages` options; `junior check` rejects both. ## Add trusted runtime hooks -Most plugins should stay manifest-only. Add trusted runtime hooks only when the -plugin must force deterministic behavior at a Junior-owned boundary, such as -installing sandbox helper files or mutating tool input/env before execution. -Trusted hooks are backend code and must be registered explicitly from app code; -Junior never loads them from `plugin.yaml`. +Most plugins should stay manifest-only. Use a JavaScript plugin definition only +when the plugin must force deterministic behavior at a Junior-owned boundary, +such as installing sandbox helper files or mutating tool input/env before +execution. Trusted hooks are backend code and must be registered explicitly from +app code; Junior never loads them from `plugin.yaml`. Trusted hook contexts include `ctx.plugin` and `ctx.log`. Use `ctx.log` for plugin-scoped structured logs instead of writing directly to stdout. @@ -154,9 +131,10 @@ import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; export function myProviderPlugin() { return defineJuniorPlugin({ - name: "my-provider", - pluginConfig: { - packages: ["@acme/junior-my-provider"], + manifest: { + name: "my-provider", + description: "My provider integration", + configKeys: ["org"], }, hooks: { async sandboxPrepare(ctx) { @@ -176,24 +154,24 @@ export function myProviderPlugin() { } ``` +Do not ship `plugin.yaml` for the same plugin. The JavaScript definition owns +both the manifest surface and the trusted hooks. If the same package also ships +`skills/`, add `packageName: "@acme/junior-my-provider"` so Nitro copies those +skills into the deployment bundle. + Register the trusted plugin from the app: ```ts title="server.ts" import { createApp } from "@sentry/junior"; -import { myProviderPlugin } from "@acme/junior-my-provider"; +import { plugins } from "./plugins"; const app = await createApp({ - plugins: [myProviderPlugin()], + plugins, }); export default app; ``` -`pluginConfig.packages` should include the package that contains `plugin.yaml` -so the trusted registration also loads the declarative provider metadata. Any -packages declared through `juniorNitro({ plugins })` continue to load; trusted -plugin package config is merged with the build-time plugin catalog. - Use `ctx.decision.replaceInput(...)` only with object-shaped tool input. Junior rejects non-object replacements before the tool runs. @@ -217,7 +195,10 @@ import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; export function myProviderPlugin() { return defineJuniorPlugin({ - name: "my-provider", + manifest: { + name: "my-provider", + description: "My provider integration", + }, hooks: { tools(ctx) { return { @@ -246,7 +227,10 @@ import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; export function myProviderPlugin() { return defineJuniorPlugin({ - name: "my-provider", + manifest: { + name: "my-provider", + description: "My provider integration", + }, hooks: { async heartbeat(ctx) { const lastDispatch = await ctx.state.get<{ id: string }>( diff --git a/packages/docs/src/content/docs/extend/datadog-plugin.md b/packages/docs/src/content/docs/extend/datadog-plugin.md index 213255015..1e4a3f4ba 100644 --- a/packages/docs/src/content/docs/extend/datadog-plugin.md +++ b/packages/docs/src/content/docs/extend/datadog-plugin.md @@ -25,14 +25,13 @@ pnpm add @sentry/junior @sentry/junior-datadog ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-datadog"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-datadog"]); ``` Set Datadog credentials in your Junior deployment environment: diff --git a/packages/docs/src/content/docs/extend/github-plugin.md b/packages/docs/src/content/docs/extend/github-plugin.md index 2aa5b1f40..07c8de7b3 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -21,35 +21,20 @@ pnpm add @sentry/junior @sentry/junior-github ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })` so the -manifest, runtime dependencies, and bundled skills are copied into the deployed -function: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-github"], - }, -}); -``` - -Register the trusted GitHub plugin in `createApp()` so Junior can enforce Git -commit attribution at runtime: +Add the trusted plugin factory to the shared plugin set used by both +`juniorNitro()` and `createApp()`. The factory registers the GitHub manifest, +bundled skills, and Git commit attribution hooks together. -```ts title="server.ts" -import { createApp } from "@sentry/junior"; +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; import { githubPlugin } from "@sentry/junior-github"; -const app = await createApp({ - plugins: [ - githubPlugin({ - botNameEnv: "GITHUB_APP_BOT_NAME", - botEmailEnv: "GITHUB_APP_BOT_EMAIL", - }), - ], -}); - -export default app; +export const plugins = defineJuniorPlugins([ + githubPlugin({ + botNameEnv: "GITHUB_APP_BOT_NAME", + botEmailEnv: "GITHUB_APP_BOT_EMAIL", + }), +]); ``` ## Configure environment variables diff --git a/packages/docs/src/content/docs/extend/hex-plugin.md b/packages/docs/src/content/docs/extend/hex-plugin.md index bd214c514..8a337b98f 100644 --- a/packages/docs/src/content/docs/extend/hex-plugin.md +++ b/packages/docs/src/content/docs/extend/hex-plugin.md @@ -25,14 +25,13 @@ pnpm add @sentry/junior @sentry/junior-hex ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-hex"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-hex"]); ``` ## Auth model diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index 7a27834f3..8cf810b90 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -22,7 +22,7 @@ Junior plugins are manifest-owned provider integrations. A plugin package can al ## Where plugins live -A plugin declares: +A declarative plugin declares: - A manifest (`plugin.yaml`) that declares optional capabilities, optional config keys, and optional credential behavior. - Optional skills (`SKILL.md`) that consume those capabilities at runtime. @@ -39,7 +39,8 @@ app/plugins// Use this when you want fast iteration inside a single app without publishing packages. -For shared integrations, publish the same shape as an npm package: +For shared manifest-only integrations, publish the same shape as an npm +package: ```text my-junior-plugin/ @@ -58,29 +59,41 @@ For reuse across apps or teams, package plugin manifests and any bundled skills pnpm add @sentry/junior @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-scheduler @sentry/junior-sentry @sentry/junior-vercel ``` -List the plugin packages in `juniorNitro` so they are bundled at build time and available at runtime: +Create one shared plugin set and pass it to both `juniorNitro()` and +`createApp()`. Manifest-only packages use package-name strings. Plugins that +need trusted runtime hooks use JavaScript factories such as `githubPlugin()` +and `schedulerPlugin()`. + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; +import { githubPlugin } from "@sentry/junior-github"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; + +export const plugins = defineJuniorPlugins([ + "@sentry/junior-agent-browser", + "@sentry/junior-datadog", + githubPlugin({ + botNameEnv: "GITHUB_APP_BOT_NAME", + botEmailEnv: "GITHUB_APP_BOT_EMAIL", + }), + "@sentry/junior-hex", + "@sentry/junior-linear", + "@sentry/junior-notion", + schedulerPlugin(), + "@sentry/junior-sentry", +]); +``` ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; +import { plugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: [ - "@sentry/junior-agent-browser", - "@sentry/junior-datadog", - "@sentry/junior-github", - "@sentry/junior-hex", - "@sentry/junior-linear", - "@sentry/junior-notion", - "@sentry/junior-scheduler", - "@sentry/junior-sentry", - "@sentry/junior-vercel", - ], - }, + plugins, }), ], routes: { @@ -89,59 +102,53 @@ export default defineConfig({ }); ``` -Use the same `plugins` config to adjust packaged manifest defaults at install time: +```ts title="server.ts" +import { createApp } from "@sentry/junior"; +import { plugins } from "./plugins"; -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-github"], - manifests: { - github: { - credentials: { - domains: ["api.github.com", "github.com"], - }, - oauth: { - scope: "repo read:org workflow", - }, +const app = await createApp({ plugins }); + +export default app; +``` + +Use the second `defineJuniorPlugins` argument to adjust packaged manifest +defaults at install time: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-sentry"], { + manifests: { + sentry: { + credentials: { + domains: ["sentry.io", "us.sentry.io"], + }, + oauth: { + scope: "event:read org:read project:read", }, }, }, }); ``` -If you publish your own package with bundled skills, include both `plugin.yaml` and `skills` in package `files`. Manifest-only packages can include just `plugin.yaml`. +If you publish a manifest-only package with bundled skills, include +`plugin.yaml` and `skills` in package `files`. If the package needs trusted +runtime hooks, export a JavaScript plugin factory instead of shipping +`plugin.yaml`. ## Trusted runtime hooks -Some packaged plugins also export trusted runtime hooks for deterministic -behavior that cannot live in skill prose or `plugin.yaml`. For example, the -scheduler plugin registers schedule-management tools and heartbeat behavior, and -the GitHub plugin installs a sandbox Git hook, configures global Git defaults, -and injects commit attribution env before bash commands run. - -Trusted hooks are explicit app code: - -```ts title="server.ts" -import { createApp } from "@sentry/junior"; -import { githubPlugin } from "@sentry/junior-github"; -import { schedulerPlugin } from "@sentry/junior-scheduler"; - -const app = await createApp({ - plugins: [ - schedulerPlugin(), - githubPlugin({ - botNameEnv: "GITHUB_APP_BOT_NAME", - botEmailEnv: "GITHUB_APP_BOT_EMAIL", - }), - ], -}); - -export default app; -``` +Most plugins are manifest-only. Use a JavaScript plugin factory instead when a +package needs deterministic host behavior that cannot live in skill prose or +`plugin.yaml`. For example, the scheduler plugin registers schedule-management +tools and heartbeat behavior, and the GitHub plugin installs a sandbox Git +hook, configures global Git defaults, and injects commit attribution env before +bash commands run. -Do not put trusted code entrypoints in `plugin.yaml`; manifests stay -declarative. Use [Build a Plugin](/extend/build-a-plugin/) for the package -authoring contract. +Trusted hooks are explicit app code because the app imports the plugin factory +into `plugins.ts`. A package should use either `plugin.yaml` or +`defineJuniorPlugin({ manifest, hooks })`, not both. Use +[Build a Plugin](/extend/build-a-plugin/) for the package authoring contract. ## Local skills vs plugin skills @@ -154,7 +161,10 @@ Use `app/skills` for skills that do not belong to a plugin. Use plugin skills wh ## Build your own plugin -Every custom plugin needs a `plugin.yaml`. Add bundled skills only when the package should also teach the agent provider-specific workflows. +Most custom plugins should be declarative and use `plugin.yaml`. Add bundled +skills only when the package should also teach the agent provider-specific +workflows. Use a JavaScript plugin factory instead when the same package needs +trusted runtime hooks. ### Minimal manifest @@ -316,7 +326,8 @@ description: Work with My Provider resources. ### Package it for discovery -Published plugin packages must include `plugin.yaml` and `skills` in `files`. +Published manifest-only plugin packages must include `plugin.yaml` and any +bundled `skills` in `files`. ```json { @@ -333,7 +344,8 @@ Then install it in the host app: pnpm add @acme/junior-example ``` -The `juniorNitro({ plugins: { packages: [...] } })` module includes `app/**/*` and the declared plugin package content in the deployed function bundle. The plugin list is automatically available at runtime via `createApp()` for declarative manifest behavior. Plugins that export trusted runtime hooks, such as `@sentry/junior-scheduler` and `@sentry/junior-github`, must also be registered from app code. +Add the package name to `defineJuniorPlugins(...)`, then pass the same plugin +set to `juniorNitro({ plugins })` and `createApp({ plugins })`. ## Validate extensions diff --git a/packages/docs/src/content/docs/extend/linear-plugin.md b/packages/docs/src/content/docs/extend/linear-plugin.md index 5f499d19a..01cd3fe86 100644 --- a/packages/docs/src/content/docs/extend/linear-plugin.md +++ b/packages/docs/src/content/docs/extend/linear-plugin.md @@ -23,14 +23,13 @@ pnpm add @sentry/junior @sentry/junior-linear ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-linear"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-linear"]); ``` ## Auth model diff --git a/packages/docs/src/content/docs/extend/notion-plugin.md b/packages/docs/src/content/docs/extend/notion-plugin.md index 90d8edc61..568ff437c 100644 --- a/packages/docs/src/content/docs/extend/notion-plugin.md +++ b/packages/docs/src/content/docs/extend/notion-plugin.md @@ -25,14 +25,13 @@ pnpm add @sentry/junior @sentry/junior-notion ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-notion"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-notion"]); ``` ## Auth model diff --git a/packages/docs/src/content/docs/extend/scheduler-plugin.md b/packages/docs/src/content/docs/extend/scheduler-plugin.md index dc4b51800..d57a5eea9 100644 --- a/packages/docs/src/content/docs/extend/scheduler-plugin.md +++ b/packages/docs/src/content/docs/extend/scheduler-plugin.md @@ -22,27 +22,15 @@ Install the package next to `@sentry/junior`: pnpm add @sentry/junior-scheduler ``` -Register the trusted plugin in app code: +Add the trusted plugin factory to the shared plugin set used by both +`juniorNitro()` and `createApp()`. The factory registers the scheduler +manifest, schedule-management tools, and heartbeat behavior together. -```ts title="server.ts" -import { createApp } from "@sentry/junior"; +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; import { schedulerPlugin } from "@sentry/junior-scheduler"; -const app = await createApp({ - plugins: [schedulerPlugin()], -}); - -export default app; -``` - -List the package in `juniorNitro()` as well so Nitro bundles the manifest: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-scheduler"], - }, -}); +export const plugins = defineJuniorPlugins([schedulerPlugin()]); ``` The scaffolded `vercel.json` includes the internal heartbeat route: diff --git a/packages/docs/src/content/docs/extend/sentry-plugin.md b/packages/docs/src/content/docs/extend/sentry-plugin.md index 6d928e93d..730f31161 100644 --- a/packages/docs/src/content/docs/extend/sentry-plugin.md +++ b/packages/docs/src/content/docs/extend/sentry-plugin.md @@ -23,14 +23,13 @@ pnpm add @sentry/junior @sentry/junior-sentry ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-sentry"], - }, -}); +Add the package name to the shared plugin set used by both `juniorNitro()` +and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-sentry"]); ``` ## Configure environment variables diff --git a/packages/docs/src/content/docs/reference/api/README.md b/packages/docs/src/content/docs/reference/api/README.md index ba142f3fc..4d63fcbca 100644 --- a/packages/docs/src/content/docs/reference/api/README.md +++ b/packages/docs/src/content/docs/reference/api/README.md @@ -9,11 +9,18 @@ title: "@sentry/junior" - [JuniorAppOptions](/reference/api/interfaces/juniorappoptions/) - [JuniorNitroOptions](/reference/api/interfaces/juniornitrooptions/) +- [JuniorPluginSet](/reference/api/interfaces/juniorpluginset/) +- [JuniorPluginSetOptions](/reference/api/interfaces/juniorpluginsetoptions/) - [JuniorVercelConfigOptions](/reference/api/interfaces/juniorvercelconfigoptions/) +## Type Aliases + +- [JuniorPluginInput](/reference/api/type-aliases/juniorplugininput/) + ## Functions - [createApp](/reference/api/functions/createapp/) +- [defineJuniorPlugins](/reference/api/functions/definejuniorplugins/) - [initSentry](/reference/api/functions/initsentry/) - [juniorNitro](/reference/api/functions/juniornitro/) - [juniorVercelConfig](/reference/api/functions/juniorvercelconfig/) diff --git a/packages/docs/src/content/docs/reference/api/functions/createApp.md b/packages/docs/src/content/docs/reference/api/functions/createApp.md index 4610ab481..2372aa7b0 100644 --- a/packages/docs/src/content/docs/reference/api/functions/createApp.md +++ b/packages/docs/src/content/docs/reference/api/functions/createApp.md @@ -7,7 +7,7 @@ title: "createApp" > **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\> -Defined in: [app.ts:216](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L216) +Defined in: [app.ts:228](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L228) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md b/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md new file mode 100644 index 000000000..acbb32b18 --- /dev/null +++ b/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "defineJuniorPlugins" +--- + +> **defineJuniorPlugins**(`inputs`, `options?`): [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) + +Defined in: plugins.ts:102 + +Define package-name plugins and JS plugin definitions for one app. + +## Parameters + +### inputs + +[`JuniorPluginInput`](/reference/api/type-aliases/juniorplugininput/)[] + +### options? + +[`JuniorPluginSetOptions`](/reference/api/interfaces/juniorpluginsetoptions/) = `{}` + +## Returns + +[`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) diff --git a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md index c488f9885..80f52d6e5 100644 --- a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md +++ b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md @@ -7,7 +7,7 @@ title: "juniorNitro" > **juniorNitro**(`options?`): `object` -Defined in: [nitro.ts:26](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L26) +Defined in: [nitro.ts:30](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L30) Nitro module that copies app and plugin content into the Vercel build output. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index fb6aaf660..f8c494154 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorAppOptions" --- -Defined in: [app.ts:35](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L35) +Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L43) ## Properties @@ -13,7 +13,7 @@ Defined in: [app.ts:35](https://github.com/getsentry/junior/blob/main/packages/j > `optional` **configDefaults?**: `Record`\<`string`, `unknown`\> -Defined in: [app.ts:37](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L37) +Defined in: [app.ts:45](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L45) Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. @@ -21,15 +21,11 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p ### plugins? -> `optional` **plugins?**: `PluginConfig` \| `JuniorPlugin`[] - -Defined in: [app.ts:45](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L45) +> `optional` **plugins?**: [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) -Plugin packages/overrides, or trusted plugin instances loaded by this app. +Defined in: [app.ts:47](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L47) -Use `PluginConfig` for declarative package lists and manifest overrides. -Use `JuniorPlugin[]` for trusted plugin factories such as `githubPlugin()`; -their package config is merged with the catalog bundled by `juniorNitro()`. +Plugin package names and JS definitions shared with `juniorNitro()`. *** @@ -37,4 +33,4 @@ their package config is merged with the catalog bundled by `juniorNitro()`. > `optional` **waitUntil?**: `WaitUntilFn` -Defined in: [app.ts:46](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L46) +Defined in: [app.ts:48](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L48) diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md index 627326afd..996be00e4 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorNitroOptions" --- -Defined in: [nitro.ts:11](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L11) +Defined in: [nitro.ts:15](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L15) ## Properties @@ -13,7 +13,7 @@ Defined in: [nitro.ts:11](https://github.com/getsentry/junior/blob/main/packages > `optional` **cwd?**: `string` -Defined in: [nitro.ts:12](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L12) +Defined in: [nitro.ts:16](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L16) *** @@ -21,7 +21,7 @@ Defined in: [nitro.ts:12](https://github.com/getsentry/junior/blob/main/packages > `optional` **includeFiles?**: `string`[] -Defined in: [nitro.ts:22](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L22) +Defined in: [nitro.ts:26](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L26) Extra file patterns to copy into the server output for files that the bundler cannot trace (e.g. dynamically imported providers). @@ -34,14 +34,14 @@ module resolution. Example: `"@earendil-works/pi-ai/dist/providers/*.js"` > `optional` **maxDuration?**: `number` -Defined in: [nitro.ts:13](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L13) +Defined in: [nitro.ts:17](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L17) *** ### plugins? -> `optional` **plugins?**: `PluginConfig` +> `optional` **plugins?**: [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) -Defined in: [nitro.ts:15](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L15) +Defined in: [nitro.ts:19](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L19) -Plugin packages and manifest overrides bundled into the app. +Plugin package names and JS definitions bundled into the app. Pass the same set to `createApp()`. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md new file mode 100644 index 000000000..71fe1a390 --- /dev/null +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md @@ -0,0 +1,40 @@ +--- +editUrl: false +next: false +prev: false +title: "JuniorPluginSet" +--- + +Defined in: plugins.ts:16 + +Reusable plugin registrations and manifest overrides. + +## Properties + +### manifests? + +> `optional` **manifests?**: `Record`\<`string`, `PluginManifestConfig`\> + +Defined in: plugins.ts:18 + +Install-level manifest overrides applied before validation. + +--- + +### packageNames + +> **packageNames**: `string`[] + +Defined in: plugins.ts:20 + +Manifest-only plugin packages included by package name. + +--- + +### registrations + +> **registrations**: `JuniorPluginRegistration`[] + +Defined in: plugins.ts:22 + +JavaScript plugin definitions included by package factories. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md new file mode 100644 index 000000000..1e68cc04b --- /dev/null +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md @@ -0,0 +1,18 @@ +--- +editUrl: false +next: false +prev: false +title: "JuniorPluginSetOptions" +--- + +Defined in: plugins.ts:10 + +## Properties + +### manifests? + +> `optional` **manifests?**: `Record`\<`string`, `PluginManifestConfig`\> + +Defined in: plugins.ts:12 + +Install-level manifest overrides applied before validation. diff --git a/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md b/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md new file mode 100644 index 000000000..aa7012d85 --- /dev/null +++ b/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "JuniorPluginInput" +--- + +> **JuniorPluginInput** = `JuniorPluginRegistration` \| `string` + +Defined in: plugins.ts:8 diff --git a/packages/docs/src/content/docs/start-here/existing-app.md b/packages/docs/src/content/docs/start-here/existing-app.md index af1e54529..c766a6927 100644 --- a/packages/docs/src/content/docs/start-here/existing-app.md +++ b/packages/docs/src/content/docs/start-here/existing-app.md @@ -24,8 +24,9 @@ import { initSentry } from "@sentry/junior/instrumentation"; initSentry(); import { createApp } from "@sentry/junior"; +import { plugins } from "./plugins"; -const app = await createApp(); +const app = await createApp({ plugins }); export default app; ``` @@ -34,18 +35,24 @@ export default app; ## Add Nitro wiring -Register `juniorNitro()` so app files and declared plugin packages are copied into the deployment bundle: +Create a shared plugin set and register `juniorNitro()` so app files and +declared plugin packages are copied into the deployment bundle: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-sentry"]); +``` ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; +import { plugins } from "./plugins"; export default defineConfig({ modules: [ juniorNitro({ - plugins: { - packages: ["@sentry/junior-sentry"], - }, + plugins, }), ], routes: { @@ -56,10 +63,10 @@ export default defineConfig({ If your existing app already owns routes, make sure the Junior Hono app still receives the paths under `/api/webhooks`, `/api/oauth/callback`, `/api/internal/turn-resume`, and `/health`. Do not split those routes across independent runtime instances. When mounted, `@sentry/junior-dashboard` owns `/`, `/api/dashboard/*`, and `/api/auth/*`. -Some packages also export trusted runtime hooks. Register those in `createApp()`; -do not rely on `juniorNitro()` alone. For example, see +Some packages export trusted runtime hooks instead of `plugin.yaml`. Add those +plugin factories to the same `plugins.ts` set. For example, see [Scheduler Plugin](/extend/scheduler-plugin/) for scheduled tasks and -[GitHub Plugin](/extend/github-plugin/) for the `githubPlugin()` app-code setup. +[GitHub Plugin](/extend/github-plugin/) for the `githubPlugin()` setup. ## Add app files diff --git a/packages/docs/src/content/docs/start-here/quickstart.md b/packages/docs/src/content/docs/start-here/quickstart.md index 8ec46ffb9..d7d967088 100644 --- a/packages/docs/src/content/docs/start-here/quickstart.md +++ b/packages/docs/src/content/docs/start-here/quickstart.md @@ -103,29 +103,40 @@ Install only the plugins you plan to enable: pnpm add @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-scheduler @sentry/junior-sentry @sentry/junior-vercel ``` -Then list them in `nitro.config.ts`: +Then create one shared plugin set: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; +import { githubPlugin } from "@sentry/junior-github"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; + +export const plugins = defineJuniorPlugins([ + "@sentry/junior-agent-browser", + "@sentry/junior-datadog", + githubPlugin({ + botNameEnv: "GITHUB_APP_BOT_NAME", + botEmailEnv: "GITHUB_APP_BOT_EMAIL", + }), + "@sentry/junior-hex", + "@sentry/junior-linear", + "@sentry/junior-notion", + schedulerPlugin(), + "@sentry/junior-sentry", +]); +``` + +Pass that same `plugins` value to `juniorNitro()` and `createApp()`: ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; +import { plugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: [ - "@sentry/junior-agent-browser", - "@sentry/junior-datadog", - "@sentry/junior-github", - "@sentry/junior-hex", - "@sentry/junior-linear", - "@sentry/junior-notion", - "@sentry/junior-scheduler", - "@sentry/junior-sentry", - "@sentry/junior-vercel", - ], - }, + plugins, }), ], routes: { @@ -134,17 +145,25 @@ export default defineConfig({ }); ``` +```ts title="server.ts" +import { createApp } from "@sentry/junior"; +import { plugins } from "./plugins"; + +const app = await createApp({ plugins }); + +export default app; +``` + Run the app check after changing plugins or skills: ```bash pnpm check ``` -Plugins with trusted runtime hooks need one more app-code registration step. -For example, `@sentry/junior-scheduler` must be registered with -`schedulerPlugin()` inside `createApp()` to enable scheduled tasks, and -`@sentry/junior-github` must be registered with `githubPlugin()` to enforce Git -commit attribution. See [Scheduler Plugin](/extend/scheduler-plugin/) and +The shared plugin set is also where trusted runtime hooks are registered. +`schedulerPlugin()` enables scheduled task tools and heartbeat behavior, and +`githubPlugin()` enforces Git commit attribution. See +[Scheduler Plugin](/extend/scheduler-plugin/) and [GitHub Plugin](/extend/github-plugin/) for those setups. ## Verify plugin content diff --git a/packages/junior-agent-browser/README.md b/packages/junior-agent-browser/README.md index 8887dc989..7e694e31f 100644 --- a/packages/junior-agent-browser/README.md +++ b/packages/junior-agent-browser/README.md @@ -8,4 +8,13 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-agent-browser ``` +Add the package name to the shared plugin set passed to both `juniorNitro()` +and `createApp()`: + +```ts +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-agent-browser"]); +``` + Full setup guide: https://junior.sentry.dev/extend/agent-browser-plugin/ diff --git a/packages/junior-datadog/README.md b/packages/junior-datadog/README.md index 975b24b7d..1e78fd6bf 100644 --- a/packages/junior-datadog/README.md +++ b/packages/junior-datadog/README.md @@ -8,14 +8,13 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-datadog ``` -Then register the plugin package in `juniorNitro(...)`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-datadog"], - }, -}); +Then add the package name to the shared plugin set passed to both +`juniorNitro()` and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-datadog"]); ``` Set Datadog credentials in the Junior deployment environment: diff --git a/packages/junior-evals/evals/behavior-harness.ts b/packages/junior-evals/evals/behavior-harness.ts index 7eeda99bd..a2faab009 100644 --- a/packages/junior-evals/evals/behavior-harness.ts +++ b/packages/junior-evals/evals/behavior-harness.ts @@ -31,7 +31,10 @@ import { getLatestMcpAuthSessionForUserProvider, } from "@/chat/mcp/auth-store"; import { getAgentPlugins, setAgentPlugins } from "@/chat/plugins/agent-hooks"; -import { getPluginOAuthConfig, setPluginConfig } from "@/chat/plugins/registry"; +import { + getPluginOAuthConfig, + setPluginCatalogConfig, +} from "@/chat/plugins/registry"; import { generateAssistantReply } from "@/chat/respond"; import { schedulerPlugin } from "@sentry/junior-scheduler"; import { getStateAdapter } from "@/chat/state/adapter"; @@ -1244,7 +1247,9 @@ async function setupHarnessEnvironment( ), }) : undefined; - setPluginConfig({ packages: scenario.overrides?.plugin_packages ?? [] }); + setPluginCatalogConfig({ + packages: scenario.overrides?.plugin_packages ?? [], + }); const stateAdapter = getStateAdapter(); await stateAdapter.connect(); @@ -1279,7 +1284,7 @@ async function setupHarnessEnvironment( }; } catch (error) { resetSkillDiscoveryCache(); - setPluginConfig(undefined); + setPluginCatalogConfig(undefined); envSnapshot.restore(); await egressServer?.close(); await pluginApp?.cleanup(); @@ -1292,7 +1297,7 @@ async function teardownHarnessEnvironment( env: HarnessEnvironment, ): Promise { resetSkillDiscoveryCache(); - setPluginConfig(undefined); + setPluginCatalogConfig(undefined); await cleanupHarnessThreadState(env.stateAdapter, scenario.events); await cleanupMcpAuthState( env.authRequesterUsers, diff --git a/packages/junior-github/README.md b/packages/junior-github/README.md index fdb576340..079fb0e8a 100644 --- a/packages/junior-github/README.md +++ b/packages/junior-github/README.md @@ -8,23 +8,19 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-github ``` -Register the trusted plugin from app code: +Add the plugin factory to the shared plugin set passed to both `juniorNitro()` +and `createApp()`: ```ts -import { createApp } from "@sentry/junior"; +import { defineJuniorPlugins } from "@sentry/junior"; import { githubPlugin } from "@sentry/junior-github"; -const app = await createApp({ - plugins: [ - githubPlugin({ - botNameEnv: "GITHUB_APP_BOT_NAME", - botEmailEnv: "GITHUB_APP_BOT_EMAIL", - }), - ], -}); +export const plugins = defineJuniorPlugins([ + githubPlugin({ + botNameEnv: "GITHUB_APP_BOT_NAME", + botEmailEnv: "GITHUB_APP_BOT_EMAIL", + }), +]); ``` -Also list `@sentry/junior-github` in `juniorNitro({ plugins: { packages: [...] } })` -so Nitro bundles the manifest and bundled GitHub skill. - Full setup guide: https://junior.sentry.dev/extend/github-plugin/ diff --git a/packages/junior-github/index.d.ts b/packages/junior-github/index.d.ts index a5d1133d0..567434e2c 100644 --- a/packages/junior-github/index.d.ts +++ b/packages/junior-github/index.d.ts @@ -1,9 +1,11 @@ -import type { JuniorPlugin } from "@sentry/junior-plugin-api"; +import type { JuniorPluginRegistration } from "@sentry/junior-plugin-api"; export interface GitHubPluginOptions { botEmailEnv?: string; botNameEnv?: string; } -/** Register trusted GitHub runtime hooks for commit attribution and package loading. */ -export function githubPlugin(options?: GitHubPluginOptions): JuniorPlugin; +/** Register GitHub manifest content and trusted commit attribution hooks. */ +export function githubPlugin( + options?: GitHubPluginOptions, +): JuniorPluginRegistration; diff --git a/packages/junior-github/index.js b/packages/junior-github/index.js index 13f2be2a2..32d0f46f0 100644 --- a/packages/junior-github/index.js +++ b/packages/junior-github/index.js @@ -84,9 +84,42 @@ export function githubPlugin(options = {}) { const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL"; return defineJuniorPlugin({ - name: "github", - pluginConfig: { - packages: ["@sentry/junior-github"], + packageName: "@sentry/junior-github", + manifest: { + name: "github", + description: + "GitHub issue, pull request, and repository workflows via GitHub App", + configKeys: ["org", "repo"], + envVars: { + GITHUB_APP_BOT_NAME: {}, + GITHUB_APP_BOT_EMAIL: {}, + }, + credentials: { + type: "github-app", + domains: ["api.github.com", "github.com"], + authTokenEnv: "GITHUB_TOKEN", + authTokenPlaceholder: "ghp_host_managed_credential", + appIdEnv: "GITHUB_APP_ID", + privateKeyEnv: "GITHUB_APP_PRIVATE_KEY", + installationIdEnv: "GITHUB_INSTALLATION_ID", + }, + commandEnv: { + GIT_AUTHOR_NAME: "${GITHUB_APP_BOT_NAME}", + GIT_AUTHOR_EMAIL: "${GITHUB_APP_BOT_EMAIL}", + GIT_COMMITTER_NAME: "${GITHUB_APP_BOT_NAME}", + GIT_COMMITTER_EMAIL: "${GITHUB_APP_BOT_EMAIL}", + }, + target: { + type: "repo", + configKey: "repo", + commandFlags: ["--repo", "-R"], + }, + runtimeDependencies: [ + { + type: "system", + package: "gh", + }, + ], }, hooks: { async sandboxPrepare(ctx) { diff --git a/packages/junior-github/package.json b/packages/junior-github/package.json index e2708bb44..d7acd47c7 100644 --- a/packages/junior-github/package.json +++ b/packages/junior-github/package.json @@ -20,7 +20,6 @@ "files": [ "index.d.ts", "index.js", - "plugin.yaml", "skills", "SETUP.md" ], diff --git a/packages/junior-github/plugin.yaml b/packages/junior-github/plugin.yaml deleted file mode 100644 index 08241d65b..000000000 --- a/packages/junior-github/plugin.yaml +++ /dev/null @@ -1,38 +0,0 @@ -name: github -description: GitHub issue, pull request, and repository workflows via GitHub App - -config-keys: - - org - - repo - -env-vars: - GITHUB_APP_BOT_NAME: - GITHUB_APP_BOT_EMAIL: - -credentials: - type: github-app - domains: - - api.github.com - - github.com - auth-token-env: GITHUB_TOKEN - auth-token-placeholder: ghp_host_managed_credential - app-id-env: GITHUB_APP_ID - private-key-env: GITHUB_APP_PRIVATE_KEY - installation-id-env: GITHUB_INSTALLATION_ID - -command-env: - GIT_AUTHOR_NAME: ${GITHUB_APP_BOT_NAME} - GIT_AUTHOR_EMAIL: ${GITHUB_APP_BOT_EMAIL} - GIT_COMMITTER_NAME: ${GITHUB_APP_BOT_NAME} - GIT_COMMITTER_EMAIL: ${GITHUB_APP_BOT_EMAIL} - -target: - type: repo - config-key: repo - command-flags: - - --repo - - -R - -runtime-dependencies: - - type: system - package: gh diff --git a/packages/junior-hex/README.md b/packages/junior-hex/README.md index 4d277f1c4..89114bc72 100644 --- a/packages/junior-hex/README.md +++ b/packages/junior-hex/README.md @@ -10,14 +10,13 @@ pnpm add @sentry/junior @sentry/junior-hex ## Configure -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: +Add the package name to the shared plugin set passed to both `juniorNitro()` +and `createApp()`: ```ts -juniorNitro({ - plugins: { - packages: ["@sentry/junior-hex"], - }, -}); +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-hex"]); ``` No API token is needed. Each user completes OAuth the first time Junior calls a Hex MCP tool on their behalf. diff --git a/packages/junior-linear/README.md b/packages/junior-linear/README.md index 9d6472092..76e2392ba 100644 --- a/packages/junior-linear/README.md +++ b/packages/junior-linear/README.md @@ -8,14 +8,13 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-linear ``` -Then register the plugin package in `juniorNitro(...)`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-linear"], - }, -}); +Then add the package name to the shared plugin set passed to both +`juniorNitro()` and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-linear"]); ``` This package does not require a shared `LINEAR_API_KEY` or a custom OAuth app for the default setup. Each user connects their own Linear account the first time Junior calls a Linear MCP tool. Junior sends the authorization link privately and resumes the same Slack thread automatically after the user authorizes. diff --git a/packages/junior-notion/README.md b/packages/junior-notion/README.md index c2814b02f..46c381f55 100644 --- a/packages/junior-notion/README.md +++ b/packages/junior-notion/README.md @@ -8,14 +8,13 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-notion ``` -Then register the plugin package in `juniorNitro(...)`: - -```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-notion"], - }, -}); +Then add the package name to the shared plugin set passed to both +`juniorNitro()` and `createApp()`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-notion"]); ``` This package does not use `NOTION_TOKEN` or a shared workspace integration. Each user connects their own Notion account the first time Junior calls a Notion MCP tool. Junior sends the OAuth link privately and resumes the thread automatically after the user authorizes. diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index b5e5bd881..a6742e659 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -222,18 +222,155 @@ export interface AgentPluginHooks { ): SlackConversationLink | undefined; } -export interface JuniorPluginConfig { - legacyStatePrefixes?: string[]; - packages?: string[]; +export interface JuniorPluginOAuthConfig { + authorizeEndpoint: string; + authorizeParams?: Record; + clientIdEnv: string; + clientSecretEnv: string; + scope?: string; + tokenAuthMethod?: "body" | "basic"; + tokenEndpoint: string; + tokenExtraHeaders?: Record; +} + +export interface JuniorPluginOAuthBearerCredentials { + apiHeaders?: Record; + authTokenEnv: string; + authTokenPlaceholder?: string; + domains: string[]; + type: "oauth-bearer"; +} + +export interface JuniorPluginGitHubAppCredentials { + apiHeaders?: Record; + appIdEnv: string; + authTokenEnv: string; + authTokenPlaceholder?: string; + domains: string[]; + installationIdEnv: string; + privateKeyEnv: string; + type: "github-app"; +} + +export type JuniorPluginCredentials = + | JuniorPluginOAuthBearerCredentials + | JuniorPluginGitHubAppCredentials; + +export interface JuniorPluginNpmRuntimeDependency { + package: string; + type: "npm"; + version: string; +} + +export interface JuniorPluginSystemRuntimeDependency { + package: string; + type: "system"; +} + +export interface JuniorPluginSystemRuntimeDependencyFromUrl { + sha256: string; + type: "system"; + url: string; +} + +export type JuniorPluginRuntimeDependency = + | JuniorPluginNpmRuntimeDependency + | JuniorPluginSystemRuntimeDependency + | JuniorPluginSystemRuntimeDependencyFromUrl; + +export interface JuniorPluginRuntimePostinstallCommand { + args?: string[]; + cmd: string; + sudo?: boolean; +} + +export interface JuniorPluginMcpConfig { + allowedTools?: string[]; + headers?: Record; + transport: "http"; + url: string; +} + +export interface JuniorPluginEnvVarDeclaration { + default?: string; } -export interface JuniorPlugin { +export interface JuniorPluginManifest { + apiHeaders?: Record; + capabilities?: string[]; + commandEnv?: Record; + configKeys?: string[]; + credentials?: JuniorPluginCredentials; + description: string; + domains?: string[]; + envVars?: Record; + mcp?: JuniorPluginMcpConfig; + name: string; + oauth?: JuniorPluginOAuthConfig; + runtimeDependencies?: JuniorPluginRuntimeDependency[]; + runtimePostinstall?: JuniorPluginRuntimePostinstallCommand[]; + target?: { + commandFlags?: string[]; + configKey: string; + type: string; + }; +} + +export type JuniorPluginRegistrationInput = { hooks?: AgentPluginHooks; + legacyStatePrefixes?: string[]; + manifest: JuniorPluginManifest; + name?: string; + packageName?: string; +}; + +export interface JuniorPluginRegistration extends JuniorPluginRegistrationInput { name: string; - pluginConfig?: JuniorPluginConfig; } -/** Define a trusted Junior plugin with optional package config and agent hooks. */ -export function defineJuniorPlugin(plugin: JuniorPlugin): JuniorPlugin { - return plugin; +const PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; + +/** Define one Junior plugin registration for app and build-time wiring. */ +export function defineJuniorPlugin( + plugin: JuniorPluginRegistrationInput, +): JuniorPluginRegistration { + if ("pluginConfig" in plugin) { + throw new Error( + "pluginConfig is no longer supported. Put runtime metadata in manifest and trusted state prefixes on the plugin registration.", + ); + } + const manifest = plugin.manifest; + if (!manifest) { + throw new Error( + "defineJuniorPlugin() requires a manifest. Use a package name string in defineJuniorPlugins([...]) for plugin.yaml packages.", + ); + } + const name = plugin.name ?? manifest.name; + if (!name) { + throw new Error( + "Junior plugin registrations must include name or manifest.name.", + ); + } + if (!PLUGIN_NAME_RE.test(name)) { + throw new Error( + `Junior plugin registration name "${name}" must be a lowercase plugin identifier.`, + ); + } + if ( + typeof manifest.description !== "string" || + !manifest.description.trim() + ) { + throw new Error( + `Junior plugin "${name}" manifest.description is required.`, + ); + } + if (plugin.name && manifest.name && plugin.name !== manifest.name) { + throw new Error( + `Junior plugin registration name "${plugin.name}" must match manifest.name "${manifest.name}".`, + ); + } + return { + ...plugin, + name, + }; } diff --git a/packages/junior-scheduler/package.json b/packages/junior-scheduler/package.json index 4a120403b..8bdfee878 100644 --- a/packages/junior-scheduler/package.json +++ b/packages/junior-scheduler/package.json @@ -19,8 +19,7 @@ }, "files": [ "dist", - "src", - "plugin.yaml" + "src" ], "scripts": { "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", diff --git a/packages/junior-scheduler/plugin.yaml b/packages/junior-scheduler/plugin.yaml deleted file mode 100644 index 9192075d9..000000000 --- a/packages/junior-scheduler/plugin.yaml +++ /dev/null @@ -1,2 +0,0 @@ -name: scheduler -description: Scheduled Junior task management and heartbeat dispatch diff --git a/packages/junior-scheduler/src/plugin.ts b/packages/junior-scheduler/src/plugin.ts index eecc31e6b..01c381c87 100644 --- a/packages/junior-scheduler/src/plugin.ts +++ b/packages/junior-scheduler/src/plugin.ts @@ -169,11 +169,11 @@ async function failClaimedRun(args: { /** Create Junior's built-in trusted scheduler plugin. */ export function createSchedulerPlugin() { return defineJuniorPlugin({ - name: "scheduler", - pluginConfig: { - legacyStatePrefixes: ["junior:scheduler"], - packages: ["@sentry/junior-scheduler"], + manifest: { + name: "scheduler", + description: "Scheduled Junior task management and heartbeat dispatch", }, + legacyStatePrefixes: ["junior:scheduler"], hooks: { tools(ctx) { if (!ctx.channelId || !ctx.teamId || !ctx.requester?.userId) { diff --git a/packages/junior-sentry/README.md b/packages/junior-sentry/README.md index 04f16bc85..ee7fcff45 100644 --- a/packages/junior-sentry/README.md +++ b/packages/junior-sentry/README.md @@ -8,6 +8,15 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-sentry ``` +Add the package name to the shared plugin set passed to both `juniorNitro()` +and `createApp()`: + +```ts +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-sentry"]); +``` + ## Sentry CLI Surface The plugin installs the npm `sentry` package as a runtime dependency and injects the current user's OAuth token as `SENTRY_AUTH_TOKEN` for Sentry skill commands. diff --git a/packages/junior/README.md b/packages/junior/README.md index f6892c08c..6cba32832 100644 --- a/packages/junior/README.md +++ b/packages/junior/README.md @@ -25,7 +25,10 @@ export default app; Run `junior init my-bot` to scaffold a complete project including `vercel.json` for Vercel deployment. -Use `juniorNitro({ plugins: { packages: [...] } })` in `nitro.config.ts` to declare which plugin packages to bundle and load at runtime. Packages with trusted runtime hooks, such as `@sentry/junior-github`, also need to be registered in app code with `createApp({ plugins: [...] })`. +Use `defineJuniorPlugins([...])` to create one plugin set, then pass it to both +`juniorNitro({ plugins })` and `createApp({ plugins })`. Manifest-only packages +use package-name strings; trusted factories such as `githubPlugin()` register +their manifest and in-process hooks together. ## Full docs diff --git a/packages/junior/skills/junior/references/examples.md b/packages/junior/skills/junior/references/examples.md index 93e069dd7..1a02ef9a2 100644 --- a/packages/junior/skills/junior/references/examples.md +++ b/packages/junior/skills/junior/references/examples.md @@ -188,7 +188,7 @@ junior-plugin-acme/ Validate: 1. Package/repo checks. -2. Add package to `plugins.packages`. +2. Add package name or trusted factory to `defineJuniorPlugins(...)`. 3. `pnpm exec junior check` for app-local files. 4. Runtime load or parser test for packaged `plugin.yaml`. 5. One real workflow after env is configured. diff --git a/packages/junior/skills/junior/references/packaging.md b/packages/junior/skills/junior/references/packaging.md index eb3ad11c2..e1b3c41c9 100644 --- a/packages/junior/skills/junior/references/packaging.md +++ b/packages/junior/skills/junior/references/packaging.md @@ -5,6 +5,7 @@ ```text my-junior-plugin/ ├── package.json +├── index.ts ├── plugin.yaml └── skills/ └── my-provider/ @@ -16,25 +17,41 @@ my-junior-plugin/ "name": "@acme/junior-my-provider", "private": false, "type": "module", - "files": ["plugin.yaml", "skills"] + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "files": ["index.d.ts", "index.js", "plugin.yaml", "skills"], + "dependencies": { + "@sentry/junior-plugin-api": "workspace:*" + } } ``` ## Host app wiring -Install next to `@sentry/junior`, then list in `plugins.packages`. +Install next to `@sentry/junior`, then export a shared plugin set. + +```ts +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@acme/junior-my-provider"]); +``` + +Pass the same set to `juniorNitro()` and `createApp()`. ```ts import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; +import { plugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: ["@acme/junior-my-provider"], - }, + plugins, }), ], routes: { @@ -43,37 +60,31 @@ export default defineConfig({ }); ``` -For local dev paths that call `createApp()` directly, pass the same list there unless the app already centralizes it. - ```ts -const app = await createApp({ - plugins: { - packages: ["@acme/junior-my-provider"], - }, -}); +const app = await createApp({ plugins }); ``` Packages that export trusted runtime hooks must be registered from app code with -their plugin factory instead of a plain package list: +their plugin factory in the same plugin set: ```ts -import { createApp } from "@sentry/junior"; +import { defineJuniorPlugins } from "@sentry/junior"; import { myProviderPlugin } from "@acme/junior-my-provider"; -const app = await createApp({ - plugins: [myProviderPlugin()], -}); +export const plugins = defineJuniorPlugins([myProviderPlugin()]); ``` -The trusted plugin's `pluginConfig.packages` should include the package that -contains `plugin.yaml`. Nitro still owns build-time package copying. +Each factory should return `defineJuniorPlugin({ manifest, hooks })`. Use +package-name strings for packages that are only `plugin.yaml` plus optional +skills. ## Monorepo package checklist When adding a new package under this repository's `packages/` directory: - Match naming such as `@sentry/junior-`. -- Include `plugin.yaml` and `skills` in `package.json` `files`. +- For manifest-only packages, include `plugin.yaml` and optional `skills` in `package.json` `files`. +- For trusted JS packages, include the factory entrypoint and optional `skills` in `package.json` `files`. - Add a package README if users need setup or verification steps. - Keep package version aligned with the monorepo release process. - Keep release package lists aligned across `.craft.yml`, `scripts/bump-release-versions.mjs`, `.github/workflows/ci.yml`, `README.md`, and release docs. @@ -92,5 +103,5 @@ When adding a new package under this repository's `packages/` directory: - Run package-local lint/type checks when package code changes. - Run `pnpm skills:check` in this repository after changing package skill files. - Run `pnpm exec junior check` in a consumer app for app-local files. -- Validate the packaged root `plugin.yaml` by loading it through a configured host app/runtime or a targeted parser test. +- Validate packaged manifests by loading them through a configured host app/runtime or a targeted parser test. - Run `junior snapshot create` when runtime dependencies or postinstall steps need sandbox snapshot warmup. diff --git a/packages/junior/skills/junior/references/validation-and-troubleshooting.md b/packages/junior/skills/junior/references/validation-and-troubleshooting.md index 4151daf2d..9038ce45a 100644 --- a/packages/junior/skills/junior/references/validation-and-troubleshooting.md +++ b/packages/junior/skills/junior/references/validation-and-troubleshooting.md @@ -31,22 +31,22 @@ For packaged plugins, load a configured host app or add a parser test. ## Common failures -| Symptom | Likely cause | Fix | -| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `name must match directory` | Frontmatter `name` differs from folder name. | Rename the folder or the skill `name`. | -| `duplicate skill name` | App and plugin skill roots contain the same skill name. | Rename one skill and adjust trigger language. | -| `requires-capabilities is no longer supported` | Old skill-level auth metadata. | Move capabilities to `plugin.yaml`. | -| `uses-config is no longer supported` | Old skill-level config metadata. | Move config keys to `plugin.yaml`. | -| `skill instructions must not hardcode harness tool-discovery or MCP dispatcher mechanics` | Skill prose names internal dispatcher APIs or active catalog tags. | Describe provider actions in domain terms instead. | -| `api-headers requires domains` | Manifest declares headers without target domains. | Add valid `domains` or remove `api-headers`. | -| `domains requires api-headers` | Manifest declares top-level domains without headers. | Add headers or remove top-level domains. | -| `oauth requires credentials` | OAuth block has no credential delivery config. | Add `credentials.type: oauth-bearer`. | -| `oauth requires credentials.type "oauth-bearer"` | OAuth was paired with unsupported credentials. | Use bearer OAuth credentials or remove `oauth`. | -| `mcp.url references env var ... not declared` | Placeholder is not listed in `env-vars`. | Declare the env var and optional default where allowed. | -| `API header env vars must not declare defaults` | Secret-like header env var has a default. | Remove the default and set the value in deployment env. | -| `target.config-key ... must be listed in config-keys` | Target points at undeclared config. | Add the short config key to `config-keys`. | -| Plugin does not load in app | Package installed but not listed in `plugins.packages`, or local files are outside `app/plugins`. | Add package to `plugins.packages` or move files under `app/plugins`. | -| Skill does not show up | Skill is in a legacy root, has invalid frontmatter, or duplicates another skill name. | Move under `app/skills` or plugin `skills`, then rerun validation. | +| Symptom | Likely cause | Fix | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `name must match directory` | Frontmatter `name` differs from folder name. | Rename the folder or the skill `name`. | +| `duplicate skill name` | App and plugin skill roots contain the same skill name. | Rename one skill and adjust trigger language. | +| `requires-capabilities is no longer supported` | Old skill-level auth metadata. | Move capabilities to `plugin.yaml`. | +| `uses-config is no longer supported` | Old skill-level config metadata. | Move config keys to `plugin.yaml`. | +| `skill instructions must not hardcode harness tool-discovery or MCP dispatcher mechanics` | Skill prose names internal dispatcher APIs or active catalog tags. | Describe provider actions in domain terms instead. | +| `api-headers requires domains` | Manifest declares headers without target domains. | Add valid `domains` or remove `api-headers`. | +| `domains requires api-headers` | Manifest declares top-level domains without headers. | Add headers or remove top-level domains. | +| `oauth requires credentials` | OAuth block has no credential delivery config. | Add `credentials.type: oauth-bearer`. | +| `oauth requires credentials.type "oauth-bearer"` | OAuth was paired with unsupported credentials. | Use bearer OAuth credentials or remove `oauth`. | +| `mcp.url references env var ... not declared` | Placeholder is not listed in `env-vars`. | Declare the env var and optional default where allowed. | +| `API header env vars must not declare defaults` | Secret-like header env var has a default. | Remove the default and set the value in deployment env. | +| `target.config-key ... must be listed in config-keys` | Target points at undeclared config. | Add the short config key to `config-keys`. | +| Plugin does not load in app | Package installed but it is missing from `defineJuniorPlugins(...)`, or local files are outside `app/plugins`. | Add the package name or trusted factory to the shared plugin set, or move files under `app/plugins`. | +| Skill does not show up | Skill is in a legacy root, has invalid frontmatter, or duplicates another skill name. | Move under `app/skills` or plugin `skills`, then rerun validation. | ## Runtime verification diff --git a/packages/junior/src/api-reference.ts b/packages/junior/src/api-reference.ts index 82c80d990..0b6c64424 100644 --- a/packages/junior/src/api-reference.ts +++ b/packages/junior/src/api-reference.ts @@ -3,5 +3,11 @@ export type { JuniorAppOptions } from "./app"; export { initSentry } from "./instrumentation"; export { juniorNitro } from "./nitro"; export type { JuniorNitroOptions } from "./nitro"; +export { defineJuniorPlugins } from "./plugins"; +export type { + JuniorPluginInput, + JuniorPluginSet, + JuniorPluginSetOptions, +} from "./plugins"; export { juniorVercelConfig } from "./vercel"; export type { JuniorVercelConfigOptions } from "./vercel"; diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index e99d63d65..ef3d508cb 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -6,7 +6,8 @@ import { import { logException } from "@/chat/logging"; import { getPluginCatalogSignature, - setPluginConfig, + getPluginProviders, + setPluginCatalogConfig, } from "@/chat/plugins/registry"; import { type AgentPluginRouteRegistration, @@ -14,11 +15,16 @@ import { setAgentPlugins, validateAgentPlugins, } from "@/chat/plugins/agent-hooks"; -import type { PluginConfig } from "@/chat/plugins/types"; +import type { PluginCatalogConfig } from "@/chat/plugins/types"; import type { AgentPluginRouteMethod, - JuniorPlugin, + JuniorPluginRegistration, } from "@sentry/junior-plugin-api"; +import { + pluginCatalogConfigFromPluginSet, + trustedPluginRegistrationsFromPluginSet, + type JuniorPluginSet, +} from "@/plugins"; import { GET as healthGET } from "@/handlers/health"; import { POST as agentDispatchPOST } from "@/handlers/agent-dispatch"; import { GET as heartbeatGET } from "@/handlers/heartbeat"; @@ -32,20 +38,26 @@ import { POST as turnResumePOST } from "@/handlers/turn-resume"; import { POST as webhooksPOST } from "@/handlers/webhooks"; import type { WaitUntilFn } from "@/handlers/types"; +export { defineJuniorPlugins } from "@/plugins"; +export type { + JuniorPluginInput, + JuniorPluginSet, + JuniorPluginSetOptions, +} from "@/plugins"; + export interface JuniorAppOptions { /** Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. */ configDefaults?: Record; - /** - * Plugin packages/overrides, or trusted plugin instances loaded by this app. - * - * Use `PluginConfig` for declarative package lists and manifest overrides. - * Use `JuniorPlugin[]` for trusted plugin factories such as `githubPlugin()`; - * their package config is merged with the catalog bundled by `juniorNitro()`. - */ - plugins?: PluginConfig | JuniorPlugin[]; + /** Plugin package names and JS definitions shared with `juniorNitro()`. */ + plugins?: JuniorPluginSet; waitUntil?: WaitUntilFn; } +interface JuniorVirtualConfig { + plugins?: PluginCatalogConfig; + trustedPluginRegistrations: string[]; +} + /** Build a `WaitUntilFn`, preferring Vercel's lifetime extension when available. */ async function defaultWaitUntil(): Promise { try { @@ -63,11 +75,19 @@ async function defaultWaitUntil(): Promise { } } -/** Resolve plugin configuration from the virtual module injected by juniorNitro(). */ -async function resolveVirtualPluginConfig(): Promise { +/** Resolve build-time configuration from the virtual module injected by juniorNitro(). */ +async function resolveVirtualConfig(): Promise< + JuniorVirtualConfig | undefined +> { try { - const mod: { plugins?: PluginConfig } = await import("#junior/config"); - return mod.plugins; + const mod: { + plugins?: PluginCatalogConfig; + trustedPluginRegistrations?: string[]; + } = await import("#junior/config"); + return { + plugins: mod.plugins, + trustedPluginRegistrations: mod.trustedPluginRegistrations ?? [], + }; } catch (error) { if (!isMissingVirtualConfig(error)) { throw error; @@ -77,10 +97,12 @@ async function resolveVirtualPluginConfig(): Promise { } /** Resolve plugin configuration from the virtual module, falling back to env. */ -async function resolveBuildPluginConfig(): Promise { - const virtualConfig = await resolveVirtualPluginConfig(); - if (virtualConfig) { - return virtualConfig; +async function resolveBuildPluginCatalogConfig(): Promise< + PluginCatalogConfig | undefined +> { + const virtualConfig = await resolveVirtualConfig(); + if (virtualConfig?.plugins) { + return virtualConfig.plugins; } const packages = readEnvPluginPackages(); @@ -130,62 +152,81 @@ function readEnvPluginPackages(): string[] | undefined { return parsed; } -function hasConfiguredPluginCatalog(config: PluginConfig | undefined): boolean { +function hasConfiguredPluginCatalog( + config: PluginCatalogConfig | undefined, +): boolean { if (!config) { return false; } return Boolean( - config.packages?.length || Object.keys(config.manifests ?? {}).length, + config.inlineManifests?.length || + config.packages?.length || + Object.keys(config.manifests ?? {}).length, ); } -function isJuniorPluginArray( - plugins: JuniorAppOptions["plugins"], -): plugins is JuniorPlugin[] { - return Array.isArray(plugins); +function pluginPackageNames(config: PluginCatalogConfig | undefined): string[] { + return config?.packages ?? []; } -function mergePluginConfig( - base: PluginConfig | undefined, - next: PluginConfig | undefined, -): PluginConfig | undefined { - if (!base) return next; - if (!next) return base; - - return { - packages: [ - ...new Set([...(base.packages ?? []), ...(next.packages ?? [])]), - ], - manifests: - base.manifests || next.manifests - ? { - ...(base.manifests ?? {}), - ...(next.manifests ?? {}), - } - : undefined, - }; +function validateBuildIncludesPluginPackages( + pluginConfig: PluginCatalogConfig | undefined, + virtualConfig: JuniorVirtualConfig | undefined, +): void { + if (!virtualConfig?.plugins) { + return; + } + const bundled = new Set(pluginPackageNames(virtualConfig.plugins)); + const missing = pluginPackageNames(pluginConfig).filter( + (packageName) => !bundled.has(packageName), + ); + if (missing.length === 0) { + return; + } + throw new Error( + `createApp() registered plugin package(s) not bundled by juniorNitro(): ${missing.join(", ")}. Pass the same defineJuniorPlugins(...) set to juniorNitro({ plugins }) and createApp({ plugins }).`, + ); } -function pluginConfigFromAgentPlugins( - plugins: JuniorPlugin[], -): PluginConfig | undefined { - const packages = [ - ...new Set( - plugins.flatMap((plugin) => plugin.pluginConfig?.packages ?? []), - ), - ]; - return packages.length ? { packages } : undefined; +function validateBuildIncludesTrustedRegistrations( + trustedRegistrations: JuniorPluginRegistration[], + virtualConfig: JuniorVirtualConfig | undefined, +): void { + const bundledTrustedRegistrations = + virtualConfig?.trustedPluginRegistrations ?? []; + if (bundledTrustedRegistrations.length === 0) { + return; + } + + const registered = new Set(trustedRegistrations.map((plugin) => plugin.name)); + const missing = bundledTrustedRegistrations.filter( + (pluginName) => !registered.has(pluginName), + ); + if (missing.length === 0) { + return; + } + + throw new Error( + `createApp() is missing trusted plugin registration(s) bundled by juniorNitro(): ${missing.join(", ")}. Pass the same defineJuniorPlugins(...) set to juniorNitro({ plugins }) and createApp({ plugins }).`, + ); } -/** Resolve catalog config without letting an explicit empty trusted array read env fallbacks. */ -async function resolveAgentPluginBaseConfig( - plugins: JuniorPlugin[], -): Promise { - if (plugins.length === 0) { - return resolveVirtualPluginConfig(); +function validatePluginRegistrations( + registrations: JuniorPluginRegistration[], +): void { + const loadedPlugins = getPluginProviders(); + const loadedNames = new Set( + loadedPlugins.map((plugin) => plugin.manifest.name), + ); + + for (const registration of registrations) { + if (!loadedNames.has(registration.name)) { + throw new Error( + `Plugin registration "${registration.name}" does not have a matching plugin manifest. Add an inline manifest, packageName, or app-local plugin.yaml with the same name.`, + ); + } } - return resolveBuildPluginConfig(); } /** Mount trusted plugin HTTP handlers before core routes claim those paths. */ @@ -214,20 +255,22 @@ function mountAgentPluginRoutes( /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { const configuredPlugins = options?.plugins; - const agentPlugins = isJuniorPluginArray(configuredPlugins) - ? configuredPlugins - : []; - const pluginConfig = isJuniorPluginArray(configuredPlugins) - ? mergePluginConfig( - await resolveAgentPluginBaseConfig(configuredPlugins), - pluginConfigFromAgentPlugins(configuredPlugins), - ) - : (configuredPlugins ?? (await resolveBuildPluginConfig())); + const agentPlugins = + trustedPluginRegistrationsFromPluginSet(configuredPlugins); + const virtualConfig = await resolveVirtualConfig(); + const pluginConfig = configuredPlugins + ? pluginCatalogConfigFromPluginSet(configuredPlugins) + : (virtualConfig?.plugins ?? (await resolveBuildPluginCatalogConfig())); + if (configuredPlugins) { + validateBuildIncludesPluginPackages(pluginConfig, virtualConfig); + } + validateBuildIncludesTrustedRegistrations(agentPlugins, virtualConfig); validateAgentPlugins(agentPlugins); const shouldValidatePluginCatalog = hasConfiguredPluginCatalog(pluginConfig) || + Boolean(configuredPlugins?.registrations.length) || Boolean(Object.keys(options?.configDefaults ?? {}).length); - const previousPluginConfig = setPluginConfig(pluginConfig); + const previousPluginCatalogConfig = setPluginCatalogConfig(pluginConfig); const previousAgentPlugins = setAgentPlugins(agentPlugins); const previousConfigDefaults = getConfigDefaults(); let agentPluginRoutes: AgentPluginRouteRegistration[] = []; @@ -235,10 +278,11 @@ export async function createApp(options?: JuniorAppOptions): Promise { setConfigDefaults(options?.configDefaults); if (shouldValidatePluginCatalog) { getPluginCatalogSignature(); + validatePluginRegistrations(configuredPlugins?.registrations ?? []); } agentPluginRoutes = getAgentPluginRoutes(); } catch (error) { - setPluginConfig(previousPluginConfig); + setPluginCatalogConfig(previousPluginCatalogConfig); setAgentPlugins(previousAgentPlugins); setConfigDefaults(previousConfigDefaults); throw error; diff --git a/packages/junior/src/build/virtual-config.ts b/packages/junior/src/build/virtual-config.ts index d6b5c4242..6fbde235b 100644 --- a/packages/junior/src/build/virtual-config.ts +++ b/packages/junior/src/build/virtual-config.ts @@ -1,11 +1,16 @@ import type { Nitro } from "nitro/types"; -import type { PluginConfig } from "@/chat/plugins/types"; +import type { PluginCatalogConfig } from "@/chat/plugins/types"; /** Inject a virtual module so createApp() can read the plugin list at runtime. */ export function injectVirtualConfig( nitro: Nitro, - plugins?: PluginConfig, + options: { + plugins?: PluginCatalogConfig; + trustedPluginRegistrations?: string[]; + } = {}, ): void { - nitro.options.virtual["#junior/config"] = - `export const plugins = ${JSON.stringify(plugins ?? { packages: [] })};`; + nitro.options.virtual["#junior/config"] = [ + `export const plugins = ${JSON.stringify(options.plugins ?? { packages: [] })};`, + `export const trustedPluginRegistrations = ${JSON.stringify(options.trustedPluginRegistrations ?? [])};`, + ].join("\n"); } diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index 2f51c1fc9..303e5895e 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -158,7 +158,7 @@ export async function runTrustedPluginHeartbeats(args: { Promise.resolve( heartbeat( createHeartbeatContext({ - legacyStatePrefixes: plugin.pluginConfig?.legacyStatePrefixes, + legacyStatePrefixes: plugin.legacyStatePrefixes, plugin: plugin.name, nowMs: args.nowMs, }), diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 676977f3f..04432c24d 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -4,7 +4,7 @@ import type { AgentPluginRouteMethod, AgentPluginSandbox, SlackConversationLink, - JuniorPlugin, + JuniorPluginRegistration, } from "@sentry/junior-plugin-api"; import { logInfo } from "@/chat/logging"; import { createAgentPluginLogger } from "@/chat/plugins/logging"; @@ -44,7 +44,7 @@ export interface AgentPluginHookRunner { prepareSandbox(sandbox: SandboxInstance): Promise; } -let agentPlugins: JuniorPlugin[] = []; +let agentPlugins: JuniorPluginRegistration[] = []; const AGENT_PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; const AGENT_PLUGIN_TOOL_NAME_RE = /^[a-z][A-Za-z0-9]*$/; const AGENT_PLUGIN_ROUTE_METHODS = new Set([ @@ -58,8 +58,8 @@ const AGENT_PLUGIN_ROUTE_METHODS = new Set([ "ALL", ]); -function validateLegacyStatePrefixes(plugin: JuniorPlugin): void { - const prefixes = plugin.pluginConfig?.legacyStatePrefixes; +function validateLegacyStatePrefixes(plugin: JuniorPluginRegistration): void { + const prefixes = plugin.legacyStatePrefixes; if (prefixes === undefined) { return; } @@ -86,7 +86,9 @@ function validateLegacyStatePrefixes(plugin: JuniorPlugin): void { } /** Validate trusted plugin identity before it can affect process-wide hooks. */ -export function validateAgentPlugins(plugins: JuniorPlugin[]): void { +export function validateAgentPlugins( + plugins: JuniorPluginRegistration[], +): void { const seen = new Set(); for (const plugin of plugins) { if (!AGENT_PLUGIN_NAME_RE.test(plugin.name)) { @@ -103,7 +105,9 @@ export function validateAgentPlugins(plugins: JuniorPlugin[]): void { } /** Replace trusted agent plugins and return the previous list for rollback. */ -export function setAgentPlugins(plugins: JuniorPlugin[]): JuniorPlugin[] { +export function setAgentPlugins( + plugins: JuniorPluginRegistration[], +): JuniorPluginRegistration[] { validateAgentPlugins(plugins); const previous = agentPlugins; agentPlugins = [...plugins].sort((left, right) => @@ -113,7 +117,7 @@ export function setAgentPlugins(plugins: JuniorPlugin[]): JuniorPlugin[] { } /** Return the current trusted agent plugins without exposing mutable state. */ -export function getAgentPlugins(): JuniorPlugin[] { +export function getAgentPlugins(): JuniorPluginRegistration[] { return [...agentPlugins]; } @@ -139,7 +143,7 @@ export function getAgentPluginTools( threadTs: context.threadTs, userText: context.userText, state: createPluginState(plugin.name, { - legacyStatePrefixes: plugin.pluginConfig?.legacyStatePrefixes, + legacyStatePrefixes: plugin.legacyStatePrefixes, }), }); for (const [name, tool] of Object.entries(pluginTools)) { diff --git a/packages/junior/src/chat/plugins/manifest.ts b/packages/junior/src/chat/plugins/manifest.ts index cc8d4d8bc..1414284ac 100644 --- a/packages/junior/src/chat/plugins/manifest.ts +++ b/packages/junior/src/chat/plugins/manifest.ts @@ -7,7 +7,7 @@ import type { PluginOAuthConfig, OAuthBearerCredentials, PluginCredentials, - PluginConfig, + PluginCatalogConfig, PluginManifest, PluginManifestConfig, PluginNpmRuntimeDependency, @@ -441,7 +441,7 @@ function mergeManifestConfig( function applyManifestConfig( source: ManifestSource, - config: PluginConfig | undefined, + config: PluginCatalogConfig | undefined, ): ManifestSource { const name = source.name; if (typeof name !== "string") { @@ -970,7 +970,7 @@ function normalizeMcp( export function parsePluginManifest( raw: string, dir: string, - config?: PluginConfig, + config?: PluginCatalogConfig, ): PluginManifest { let parsedYaml: unknown; try { diff --git a/packages/junior/src/chat/plugins/package-discovery.ts b/packages/junior/src/chat/plugins/package-discovery.ts index ddd93430e..13ce5872e 100644 --- a/packages/junior/src/chat/plugins/package-discovery.ts +++ b/packages/junior/src/chat/plugins/package-discovery.ts @@ -16,6 +16,11 @@ interface InstalledJuniorContentPackage { export interface InstalledPluginPackageContent { packageNames: string[]; + packages: { + dir: string; + hasSkillsDir: boolean; + name: string; + }[]; manifestRoots: string[]; skillRoots: string[]; tracingIncludes: string[]; @@ -216,6 +221,11 @@ export function discoverInstalledPluginPackageContent( packageNames: uniqueStringsInOrder( discoveredPackages.map((pkg) => pkg.name), ), + packages: discoveredPackages.map((pkg) => ({ + dir: pkg.dir, + hasSkillsDir: pkg.hasSkillsDir, + name: pkg.name, + })), manifestRoots: uniqueStringsInOrder(manifestRoots), skillRoots: uniqueStringsInOrder(skillRoots), tracingIncludes: uniqueStringsInOrder(tracingIncludes), diff --git a/packages/junior/src/chat/plugins/registry.ts b/packages/junior/src/chat/plugins/registry.ts index e1d10ba89..d9cdcf7b8 100644 --- a/packages/junior/src/chat/plugins/registry.ts +++ b/packages/junior/src/chat/plugins/registry.ts @@ -14,8 +14,9 @@ import { normalizePluginPackageNames, } from "./package-discovery"; import type { + InlinePluginManifestDefinition, PluginBrokerDeps, - PluginConfig, + PluginCatalogConfig, PluginDefinition, OAuthProviderConfig, PluginRuntimeDependency, @@ -33,13 +34,15 @@ interface LoadedPluginState { } interface PluginCatalogSource { + inlineManifests: InlinePluginManifestDefinition[]; manifestRoots: string[]; packagedSkillRoots: string[]; + packagedContent: InstalledPluginPackageContent; signature: string; } let loadedPluginState: LoadedPluginState | undefined; -let pluginConfig: PluginConfig | undefined; +let pluginConfig: PluginCatalogConfig | undefined; function getLoggedPluginNames(): Set { const globalState = globalThis as typeof globalThis & { @@ -72,11 +75,10 @@ function providerDomains(manifest: PluginDefinition["manifest"]): string[] { function registerPluginManifest( state: LoadedPluginState, - raw: string, + manifest: PluginDefinition["manifest"], pluginDir: string, + skillsDir = path.join(pluginDir, "skills"), ): void { - const manifest = parsePluginManifest(raw, pluginDir, pluginConfig); - if (state.pluginsByName.has(manifest.name)) { throw new Error(`Duplicate plugin name "${manifest.name}"`); } @@ -93,7 +95,7 @@ function registerPluginManifest( const owner = state.domainToPlugin.get(domain); if (owner) { throw new Error( - `Duplicate provider domain "${domain}" in plugin "${manifest.name}" already declared by plugin "${owner}". Use plugins.manifests in PluginConfig to change one plugin's domains or credentials.`, + `Duplicate provider domain "${domain}" in plugin "${manifest.name}" already declared by plugin "${owner}". Use plugins.manifests in PluginCatalogConfig to change one plugin's domains or credentials.`, ); } } @@ -101,7 +103,7 @@ function registerPluginManifest( const definition: PluginDefinition = { manifest, dir: pluginDir, - skillsDir: path.join(pluginDir, "skills"), + skillsDir, }; state.pluginDefinitions.push(definition); @@ -118,6 +120,15 @@ function registerPluginManifest( } } +function registerYamlPluginManifest( + state: LoadedPluginState, + raw: string, + pluginDir: string, +): void { + const manifest = parsePluginManifest(raw, pluginDir, pluginConfig); + registerPluginManifest(state, manifest, pluginDir); +} + function normalizePluginRoots(roots: string[]): string[] { const resolved: string[] = []; const seen = new Set(); @@ -143,10 +154,14 @@ function getPluginCatalogSource(): PluginCatalogSource { ]); const packagedSkillRoots = normalizePluginRoots(packagedContent.skillRoots); + const inlineManifests = pluginConfig?.inlineManifests ?? []; return { + inlineManifests, manifestRoots, packagedSkillRoots, + packagedContent, signature: JSON.stringify({ + inlineManifests, manifestRoots, packagedSkillRoots, packageNames: [...packagedContent.packageNames].sort(), @@ -155,14 +170,17 @@ function getPluginCatalogSource(): PluginCatalogSource { }; } -function normalizePluginConfig( - config: PluginConfig | undefined, -): PluginConfig | undefined { +function normalizePluginCatalogConfig( + config: PluginCatalogConfig | undefined, +): PluginCatalogConfig | undefined { if (!config) { return undefined; } return { + inlineManifests: config.inlineManifests + ? structuredClone(config.inlineManifests) + : undefined, packages: normalizePluginPackageNames(config.packages), ...(config.manifests ? { manifests: structuredClone(config.manifests) } @@ -170,14 +188,17 @@ function normalizePluginConfig( }; } -function clonePluginConfig( - config: PluginConfig | undefined, -): PluginConfig | undefined { +function clonePluginCatalogConfig( + config: PluginCatalogConfig | undefined, +): PluginCatalogConfig | undefined { if (!config) { return undefined; } return { + ...(config.inlineManifests + ? { inlineManifests: structuredClone(config.inlineManifests) } + : {}), packages: [...(config.packages ?? [])], ...(config.manifests ? { manifests: structuredClone(config.manifests) } @@ -185,6 +206,34 @@ function clonePluginConfig( }; } +function packageContentByName( + packagedContent: InstalledPluginPackageContent, + packageName: string, +): { dir: string; hasSkillsDir: boolean } | undefined { + return packagedContent.packages.find((pkg) => pkg.name === packageName); +} + +function registerInlineManifests( + state: LoadedPluginState, + source: PluginCatalogSource, +): void { + for (const definition of source.inlineManifests) { + const pkg = definition.packageName + ? packageContentByName(source.packagedContent, definition.packageName) + : undefined; + const dir = pkg?.dir ?? process.cwd(); + const skillsDir = pkg?.hasSkillsDir + ? path.join(pkg.dir, "skills") + : path.join(dir, "skills"); + registerPluginManifest( + state, + structuredClone(definition.manifest), + dir, + skillsDir, + ); + } +} + function discoverConfiguredPluginPackageContent(): InstalledPluginPackageContent { return discoverInstalledPluginPackageContent(process.cwd(), { packageNames: pluginConfig?.packages, @@ -200,6 +249,8 @@ function buildLoadedPluginState( state.packageSkillRoots.add(skillRoot); } + registerInlineManifests(state, source); + const roots = source.manifestRoots; for (const pluginsRoot of roots) { let entries: string[]; @@ -229,7 +280,7 @@ function buildLoadedPluginState( } if (hasRootManifest) { const rawRootManifest = readFileSync(manifestPath, "utf8"); - registerPluginManifest(state, rawRootManifest, pluginsRoot); + registerYamlPluginManifest(state, rawRootManifest, pluginsRoot); continue; } } @@ -266,7 +317,7 @@ function buildLoadedPluginState( continue; // No manifest — skip } - registerPluginManifest(state, raw, pluginDir); + registerYamlPluginManifest(state, raw, pluginDir); } } @@ -321,11 +372,11 @@ function ensurePluginsLoaded(): LoadedPluginState { // --- Sync exports --- /** Set install-wide plugin configuration and return the previous value for rollback. */ -export function setPluginConfig( - config: PluginConfig | undefined, -): PluginConfig | undefined { - const previousConfig = clonePluginConfig(pluginConfig); - pluginConfig = normalizePluginConfig(config); +export function setPluginCatalogConfig( + config: PluginCatalogConfig | undefined, +): PluginCatalogConfig | undefined { + const previousConfig = clonePluginCatalogConfig(pluginConfig); + pluginConfig = normalizePluginCatalogConfig(config); return previousConfig; } diff --git a/packages/junior/src/chat/plugins/types.ts b/packages/junior/src/chat/plugins/types.ts index 2304d92b5..2ab0257be 100644 --- a/packages/junior/src/chat/plugins/types.ts +++ b/packages/junior/src/chat/plugins/types.ts @@ -157,8 +157,9 @@ export interface PluginManifestConfig { } | null; } -/** Install-level plugin package list and manifest configuration. */ -export interface PluginConfig { +/** Install-level plugin package list and manifest override catalog. */ +export interface PluginCatalogConfig { + inlineManifests?: InlinePluginManifestDefinition[]; packages?: string[]; manifests?: Record; } @@ -172,3 +173,8 @@ export interface PluginDefinition { dir: string; skillsDir: string; } + +export interface InlinePluginManifestDefinition { + manifest: PluginManifest; + packageName?: string; +} diff --git a/packages/junior/src/cli/check.ts b/packages/junior/src/cli/check.ts index dbdf82c37..2e4a880a4 100644 --- a/packages/junior/src/cli/check.ts +++ b/packages/junior/src/cli/check.ts @@ -573,7 +573,13 @@ async function validateAppSourceFiles( if (/\bpluginPackages\s*:/.test(source)) { errors.push( - `${sourcePath}: pluginPackages is no longer supported. Use plugins: { packages: [...] }.`, + `${sourcePath}: pluginPackages is no longer supported. Export a defineJuniorPlugins(...) set and pass it to juniorNitro({ plugins }) and createApp({ plugins }).`, + ); + } + + if (/\bplugins\s*:\s*\{\s*packages\s*:/.test(source)) { + errors.push( + `${sourcePath}: plugins.packages is no longer supported. Export a defineJuniorPlugins(...) set and pass it to juniorNitro({ plugins }) and createApp({ plugins }).`, ); } diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index 5b66b6358..4422e40df 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -6,13 +6,17 @@ import { copyIncludedFiles, } from "@/build/copy-build-content"; import { injectVirtualConfig } from "@/build/virtual-config"; -import type { PluginConfig } from "@/chat/plugins/types"; +import { + pluginCatalogConfigFromPluginSet, + trustedPluginRegistrationsFromPluginSet, + type JuniorPluginSet, +} from "@/plugins"; export interface JuniorNitroOptions { cwd?: string; maxDuration?: number; - /** Plugin packages and manifest overrides bundled into the app. */ - plugins?: PluginConfig; + /** Plugin package names and JS definitions bundled into the app. Pass the same set to `createApp()`. */ + plugins?: JuniorPluginSet; /** * Extra file patterns to copy into the server output for files that the * bundler cannot trace (e.g. dynamically imported providers). @@ -39,13 +43,23 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { options.maxDuration ?? 800; applyRolldownTreeshakeWorkaround(nitro); - injectVirtualConfig(nitro, options.plugins); + const pluginCatalogConfig = pluginCatalogConfigFromPluginSet( + options.plugins, + ); + const trustedPluginRegistrations = + trustedPluginRegistrationsFromPluginSet(options.plugins).map( + (plugin) => plugin.name, + ); + injectVirtualConfig(nitro, { + plugins: pluginCatalogConfig, + trustedPluginRegistrations, + }); nitro.hooks.hook("compiled", () => { copyAppAndPluginContent( cwd, nitro.options.output.serverDir, - options.plugins?.packages, + pluginCatalogConfig?.packages, ); copyIncludedFiles( cwd, diff --git a/packages/junior/src/plugins.ts b/packages/junior/src/plugins.ts new file mode 100644 index 000000000..70fbaae7a --- /dev/null +++ b/packages/junior/src/plugins.ts @@ -0,0 +1,162 @@ +import type { JuniorPluginRegistration } from "@sentry/junior-plugin-api"; +import type { + InlinePluginManifestDefinition, + PluginCatalogConfig, + PluginManifestConfig, +} from "@/chat/plugins/types"; + +export type JuniorPluginInput = JuniorPluginRegistration | string; + +export interface JuniorPluginSetOptions { + /** Install-level manifest overrides applied before validation. */ + manifests?: Record; +} + +/** Reusable plugin registrations and manifest overrides. */ +export interface JuniorPluginSet { + /** Install-level manifest overrides applied before validation. */ + manifests?: Record; + /** Manifest-only plugin packages included by package name. */ + packageNames: string[]; + /** JavaScript plugin definitions included by package factories. */ + registrations: JuniorPluginRegistration[]; +} + +function cloneManifests( + manifests: Record | undefined, +): Record | undefined { + return manifests ? structuredClone(manifests) : undefined; +} + +function cloneInlineManifests( + registrations: JuniorPluginRegistration[], +): InlinePluginManifestDefinition[] | undefined { + const inlineManifests = registrations.flatMap((plugin) => + plugin.manifest + ? [ + { + manifest: { + ...structuredClone(plugin.manifest), + capabilities: + plugin.manifest.capabilities?.map((capability) => + capability.includes(".") + ? capability + : `${plugin.manifest!.name}.${capability}`, + ) ?? [], + configKeys: + plugin.manifest.configKeys?.map((key) => + key.includes(".") ? key : `${plugin.manifest!.name}.${key}`, + ) ?? [], + ...(plugin.manifest.target + ? { + target: { + ...plugin.manifest.target, + configKey: plugin.manifest.target.configKey.includes(".") + ? plugin.manifest.target.configKey + : `${plugin.manifest.name}.${plugin.manifest.target.configKey}`, + }, + } + : {}), + }, + ...(plugin.packageName ? { packageName: plugin.packageName } : {}), + }, + ] + : [], + ); + return inlineManifests.length > 0 ? inlineManifests : undefined; +} + +function assertUniquePluginNames( + registrations: JuniorPluginRegistration[], +): void { + const seen = new Set(); + for (const plugin of registrations) { + if (seen.has(plugin.name)) { + throw new Error(`Duplicate plugin registration name "${plugin.name}"`); + } + seen.add(plugin.name); + } +} + +function assertUniquePackageNames(packageNames: string[]): void { + const seen = new Set(); + for (const packageName of packageNames) { + if (seen.has(packageName)) { + throw new Error(`Duplicate plugin package name "${packageName}"`); + } + seen.add(packageName); + } +} + +function normalizePluginInput(input: JuniorPluginInput): { + packageName?: string; + registration?: JuniorPluginRegistration; +} { + if (typeof input === "string") { + return { packageName: input }; + } + return { registration: input }; +} + +/** Define package-name plugins and JS plugin definitions for one app. */ +export function defineJuniorPlugins( + inputs: JuniorPluginInput[], + options: JuniorPluginSetOptions = {}, +): JuniorPluginSet { + const normalized = inputs.map(normalizePluginInput); + const packageNames = normalized.flatMap((input) => + input.packageName ? [input.packageName] : [], + ); + const registrations = normalized.flatMap((input) => + input.registration ? [input.registration] : [], + ); + assertUniquePackageNames(packageNames); + assertUniquePluginNames(registrations); + const manifests = cloneManifests(options.manifests); + return { + packageNames, + registrations: registrations.map((plugin) => ({ ...plugin })), + ...(manifests ? { manifests } : {}), + }; +} + +/** Build the manifest catalog config implied by one plugin set. */ +export function pluginCatalogConfigFromPluginSet( + pluginSet: JuniorPluginSet | undefined, +): PluginCatalogConfig | undefined { + if (!pluginSet) { + return undefined; + } + + const packages = [ + ...new Set([ + ...pluginSet.packageNames, + ...pluginSet.registrations.flatMap((plugin) => + plugin.packageName ? [plugin.packageName] : [], + ), + ]), + ]; + const manifests = cloneManifests(pluginSet.manifests); + const inlineManifests = cloneInlineManifests(pluginSet.registrations); + + if (packages.length === 0 && !manifests && !inlineManifests) { + return undefined; + } + + return { + ...(inlineManifests ? { inlineManifests } : {}), + ...(packages.length > 0 ? { packages } : {}), + ...(manifests ? { manifests } : {}), + }; +} + +/** Return registrations that expose trusted in-process runtime behavior. */ +export function trustedPluginRegistrationsFromPluginSet( + pluginSet: JuniorPluginSet | undefined, +): JuniorPluginRegistration[] { + return ( + pluginSet?.registrations.filter( + (plugin) => plugin.hooks || plugin.legacyStatePrefixes, + ) ?? [] + ); +} diff --git a/packages/junior/src/virtual-modules.d.ts b/packages/junior/src/virtual-modules.d.ts index 534062e2f..92dfd2670 100644 --- a/packages/junior/src/virtual-modules.d.ts +++ b/packages/junior/src/virtual-modules.d.ts @@ -1,6 +1,7 @@ /** Virtual module injected by juniorNitro() at build time. */ declare module "#junior/config" { - import type { PluginConfig } from "@/chat/plugins/types"; + import type { PluginCatalogConfig } from "@/chat/plugins/types"; - export const plugins: PluginConfig; + export const plugins: PluginCatalogConfig; + export const trustedPluginRegistrations: string[]; } diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts index 6686386e3..53d6a7e61 100644 --- a/packages/junior/tests/integration/heartbeat.test.ts +++ b/packages/junior/tests/integration/heartbeat.test.ts @@ -132,7 +132,10 @@ describe("trusted plugin heartbeat", () => { const seen: number[] = []; setAgentPlugins([ defineJuniorPlugin({ - name: "scheduler", + manifest: { + name: "scheduler", + description: "Scheduler test plugin", + }, hooks: { heartbeat(ctx) { seen.push(ctx.nowMs); diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index 0c13a2b2a..1610a12a3 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -3,13 +3,16 @@ import os from "node:os"; import path from "node:path"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { afterEach, describe, expect, it } from "vitest"; -import { createApp } from "@/app"; +import { createApp, defineJuniorPlugins } from "@/app"; import { getConfigDefaults, setConfigDefaults, } from "@/chat/configuration/defaults"; import { getAgentPlugins, setAgentPlugins } from "@/chat/plugins/agent-hooks"; -import { getPluginProviders, setPluginConfig } from "@/chat/plugins/registry"; +import { + getPluginProviders, + setPluginCatalogConfig, +} from "@/chat/plugins/registry"; const originalCwd = process.cwd(); const originalPluginPackages = process.env.JUNIOR_PLUGIN_PACKAGES; @@ -27,6 +30,7 @@ async function writePluginPackage( root: string, packageName: string, pluginName: string, + extraLines: string[] = [], ): Promise { const packageRoot = path.join( root, @@ -39,6 +43,7 @@ async function writePluginPackage( [ `name: ${pluginName}`, `description: ${pluginName} plugin`, + ...extraLines, "config-keys:", " - org", ].join("\n"), @@ -49,7 +54,7 @@ async function writePluginPackage( afterEach(async () => { process.chdir(originalCwd); setAgentPlugins([]); - setPluginConfig(undefined); + setPluginCatalogConfig(undefined); setConfigDefaults(undefined); if (originalPluginPackages === undefined) { delete process.env.JUNIOR_PLUGIN_PACKAGES; @@ -84,7 +89,7 @@ describe("createApp plugin config", () => { process.env.JUNIOR_PLUGIN_PACKAGES = "not-json"; await createApp({ - plugins: [], + plugins: defineJuniorPlugins([]), }); expect(getPluginProviders()).toEqual([]); @@ -124,23 +129,11 @@ describe("createApp plugin config", () => { it("fails loudly when configured plugin package names are invalid", async () => { await expect( createApp({ - plugins: { - packages: ["../plugins"], - }, + plugins: defineJuniorPlugins(["../plugins"]), }), ).rejects.toThrow("Plugin package names must be valid npm package names"); }); - it("fails loudly when configured plugin packages are not an array", async () => { - await expect( - createApp({ - plugins: { - packages: "@acme/junior-plugin" as unknown as string[], - }, - }), - ).rejects.toThrow("plugins.packages must be an array of package names"); - }); - it("rolls back plugin config when config default validation fails", async () => { const tempRoot = await makeTempDir(); await writePluginPackage(tempRoot, "@acme/base-plugin", "base"); @@ -160,13 +153,13 @@ describe("createApp plugin config", () => { process.chdir(tempRoot); await createApp({ - plugins: { packages: ["@acme/base-plugin"] }, + plugins: defineJuniorPlugins(["@acme/base-plugin"]), configDefaults: { "base.org": "sentry" }, }); await expect( createApp({ - plugins: { packages: ["@acme/next-plugin"] }, + plugins: defineJuniorPlugins(["@acme/next-plugin"]), configDefaults: { "missing.org": "sentry" }, }), ).rejects.toThrow( @@ -196,13 +189,13 @@ describe("createApp plugin config", () => { process.chdir(tempRoot); await createApp({ - plugins: { packages: ["@acme/base-plugin"] }, + plugins: defineJuniorPlugins(["@acme/base-plugin"]), configDefaults: { "base.org": "sentry" }, }); await expect( createApp({ - plugins: { packages: ["@acme/missing-plugin"] }, + plugins: defineJuniorPlugins(["@acme/missing-plugin"]), }), ).rejects.toThrow( 'Plugin package "@acme/missing-plugin" was configured but could not be resolved', @@ -215,15 +208,36 @@ describe("createApp plugin config", () => { }); it("loads trusted plugin instances through createApp", async () => { + await createApp({ + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { + name: "trusted", + description: "Trusted plugin", + configKeys: ["org"], + }, + hooks: {}, + }), + ]), + configDefaults: { "trusted.org": "sentry" }, + }); + + expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "trusted", + ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); + }); + + it("loads manifest-only package plugins by package name", async () => { const tempRoot = await makeTempDir(); - await writePluginPackage(tempRoot, "@acme/trusted-plugin", "trusted"); + await writePluginPackage(tempRoot, "@acme/full-plugin", "full"); await fs.writeFile( path.join(tempRoot, "package.json"), JSON.stringify({ name: "temp-junior-app", private: true, dependencies: { - "@acme/trusted-plugin": "1.0.0", + "@acme/full-plugin": "1.0.0", }, }), "utf8", @@ -231,34 +245,30 @@ describe("createApp plugin config", () => { process.chdir(tempRoot); await createApp({ - plugins: [ - defineJuniorPlugin({ - name: "trusted", - pluginConfig: { packages: ["@acme/trusted-plugin"] }, - }), - ], - configDefaults: { "trusted.org": "sentry" }, + plugins: defineJuniorPlugins(["@acme/full-plugin"]), }); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ - "trusted", + "full", ]); - expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); }); it("rejects duplicate trusted plugin names before mutating app config", async () => { await createApp({ - plugins: [], + plugins: defineJuniorPlugins([]), }); - await expect( - createApp({ - plugins: [ - defineJuniorPlugin({ name: "dupe" }), - defineJuniorPlugin({ name: "dupe" }), - ], - }), - ).rejects.toThrow('Duplicate trusted plugin name "dupe"'); + expect(() => + defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { name: "dupe", description: "Duplicate plugin" }, + }), + defineJuniorPlugin({ + manifest: { name: "dupe", description: "Duplicate plugin" }, + }), + ]), + ).toThrow('Duplicate plugin registration name "dupe"'); expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); expect(getPluginProviders()).toEqual([]); @@ -266,15 +276,16 @@ describe("createApp plugin config", () => { it("rejects invalid trusted plugin names before mutating app config", async () => { await createApp({ - plugins: [], + plugins: defineJuniorPlugins([]), }); - await expect( - createApp({ - plugins: [defineJuniorPlugin({ name: "GitHub" })], + expect(() => + defineJuniorPlugin({ + manifest: { name: "GitHub", description: "Invalid plugin" }, + hooks: {}, }), - ).rejects.toThrow( - 'Trusted plugin name "GitHub" must be a lowercase plugin identifier', + ).toThrow( + 'Junior plugin registration name "GitHub" must be a lowercase plugin identifier', ); expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); @@ -283,17 +294,17 @@ describe("createApp plugin config", () => { it("rejects legacy state prefixes outside the trusted plugin namespace", async () => { await createApp({ - plugins: [], + plugins: defineJuniorPlugins([]), }); await expect( createApp({ - plugins: [ + plugins: defineJuniorPlugins([ defineJuniorPlugin({ - name: "trusted", - pluginConfig: { legacyStatePrefixes: ["junior:scheduler"] }, + manifest: { name: "trusted", description: "Trusted plugin" }, + legacyStatePrefixes: ["junior:scheduler"], }), - ], + ]), }), ).rejects.toThrow( 'Trusted plugin "trusted" legacy state prefix "junior:scheduler" must stay under "junior:trusted"', diff --git a/packages/junior/tests/unit/cli/check-cli.test.ts b/packages/junior/tests/unit/cli/check-cli.test.ts index 1d88e3a90..08c53404e 100644 --- a/packages/junior/tests/unit/cli/check-cli.test.ts +++ b/packages/junior/tests/unit/cli/check-cli.test.ts @@ -211,7 +211,45 @@ describe("check cli", () => { expect( lines.some((line) => line.includes( - "pluginPackages is no longer supported. Use plugins: { packages: [...] }.", + "pluginPackages is no longer supported. Export a defineJuniorPlugins(...) set", + ), + ), + ).toBe(true); + }); + + it("fails when app source uses the removed plugins.packages option", async () => { + const repoRoot = makeTempDir("junior-validate-plugins-packages-option-"); + writeFile( + path.join(repoRoot, "nitro.config.ts"), + [ + 'import { juniorNitro } from "@sentry/junior/nitro";', + "", + "export default {", + " modules: [", + " juniorNitro({", + " plugins: { packages: ['@acme/junior-demo'] },", + " }),", + " ],", + "};", + "", + ].join("\n"), + ); + + const lines: string[] = []; + await expect( + runCheck(repoRoot, { + info: (line) => lines.push(line), + warn: (line) => lines.push(line), + error: (line) => lines.push(line), + }), + ).rejects.toThrow( + "Validation failed (1 error, 0 plugin manifests, 0 skill directories checked).", + ); + + expect( + lines.some((line) => + line.includes( + "plugins.packages is no longer supported. Export a defineJuniorPlugins(...) set", ), ), ).toBe(true); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 8f6650d8c..323f6acc5 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -66,7 +66,10 @@ describe("agent plugin hooks", () => { it("collects turn-scoped tools from configured plugins", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ - name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { tools(ctx) { expect(ctx.requester?.userId).toBe("U123"); @@ -101,7 +104,10 @@ describe("agent plugin hooks", () => { it("rejects plugin tools with invalid names", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ - name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { tools() { return { @@ -134,7 +140,10 @@ describe("agent plugin hooks", () => { it("rejects plugin tools that conflict with core tools", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ - name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { tools() { return { @@ -306,7 +315,10 @@ describe("agent plugin hooks", () => { const writes: Array<{ content: string | Uint8Array; path: string }> = []; const previous = setAgentPlugins([ defineJuniorPlugin({ - name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { async sandboxPrepare(ctx) { await ctx.sandbox.writeFile({ diff --git a/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts b/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts index 246f2aa3f..f6c622236 100644 --- a/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts +++ b/packages/junior/tests/unit/plugins/plugin-manifest-api-headers.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { parsePluginManifest } from "@/chat/plugins/manifest"; @@ -130,15 +131,12 @@ describe("plugin manifest API headers", () => { ]); }); - it("parses the packaged GitHub command env host bindings", () => { - const manifestPath = path.resolve( - process.cwd(), - "../junior-github/plugin.yaml", - ); - const manifest = parsePluginManifest( - readFileSync(manifestPath, "utf8"), - path.dirname(manifestPath), - ); + it("registers the packaged GitHub command env host bindings", async () => { + const { githubPlugin } = (await import( + pathToFileURL(path.resolve(process.cwd(), "../junior-github/index.js")) + .href + )) as typeof import("../../../../junior-github/index.js"); + const manifest = githubPlugin().manifest!; expect(manifest.envVars).toMatchObject({ GITHUB_APP_BOT_NAME: {}, diff --git a/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts b/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts index f247acbd3..b9cbc7170 100644 --- a/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts +++ b/packages/junior/tests/unit/plugins/plugin-registry-packages.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { PluginConfig } from "@/chat/plugins/types"; +import type { PluginCatalogConfig } from "@/chat/plugins/types"; const originalCwd = process.cwd(); let configuredPackageNames: string[] = []; @@ -12,9 +12,9 @@ async function setPackages(packageNames: string[]): Promise { await setConfig({ packages: packageNames }); } -async function setConfig(config: PluginConfig): Promise { - const { setPluginConfig } = await import("@/chat/plugins/registry"); - setPluginConfig({ +async function setConfig(config: PluginCatalogConfig): Promise { + const { setPluginCatalogConfig } = await import("@/chat/plugins/registry"); + setPluginCatalogConfig({ ...config, packages: config.packages ?? configuredPackageNames, }); @@ -794,7 +794,7 @@ describe("plugin registry package discovery", () => { ); }); - it("applies PluginConfig manifest overrides before duplicate domain validation", async () => { + it("applies PluginCatalogConfig manifest overrides before duplicate domain validation", async () => { const tempRoot = await fs.mkdtemp( path.join(os.tmpdir(), "junior-plugin-package-"), ); @@ -844,7 +844,7 @@ describe("plugin registry package discovery", () => { ]); }); - it("rejects PluginConfig manifest overrides for missing plugins", async () => { + it("rejects PluginCatalogConfig manifest overrides for missing plugins", async () => { const tempRoot = await fs.mkdtemp( path.join(os.tmpdir(), "junior-plugin-package-"), ); diff --git a/packages/junior/tests/unit/plugins/plugin-registry.test.ts b/packages/junior/tests/unit/plugins/plugin-registry.test.ts index 729adc5e0..64709d79f 100644 --- a/packages/junior/tests/unit/plugins/plugin-registry.test.ts +++ b/packages/junior/tests/unit/plugins/plugin-registry.test.ts @@ -25,6 +25,7 @@ describe("plugin registry", () => { vi.doMock("@/chat/plugins/package-discovery", () => ({ discoverInstalledPluginPackageContent: () => ({ packageNames: [], + packages: [], manifestRoots: [], skillRoots: [], tracingIncludes: [], @@ -54,6 +55,11 @@ describe("plugin registry", () => { it("reloads plugin state after packaged content changes", async () => { const packagedContent = { packageNames: [] as string[], + packages: [] as { + dir: string; + hasSkillsDir: boolean; + name: string; + }[], manifestRoots: [] as string[], skillRoots: [] as string[], tracingIncludes: [] as string[], diff --git a/packages/junior/tests/unit/skills-plugin-provider.test.ts b/packages/junior/tests/unit/skills-plugin-provider.test.ts index ebee0d2c4..ebe8947c9 100644 --- a/packages/junior/tests/unit/skills-plugin-provider.test.ts +++ b/packages/junior/tests/unit/skills-plugin-provider.test.ts @@ -64,6 +64,7 @@ describe("discoverSkills plugin ownership", () => { vi.doMock("@/chat/plugins/package-discovery", () => ({ discoverInstalledPluginPackageContent: () => ({ packageNames: [], + packages: [], manifestRoots: [], skillRoots: [], tracingIncludes: [], diff --git a/packages/junior/tests/unit/tools/load-skill.test.ts b/packages/junior/tests/unit/tools/load-skill.test.ts index 70c49ec63..323ce383d 100644 --- a/packages/junior/tests/unit/tools/load-skill.test.ts +++ b/packages/junior/tests/unit/tools/load-skill.test.ts @@ -58,6 +58,7 @@ describe("loadSkill tool", () => { vi.doMock("@/chat/plugins/package-discovery", () => ({ discoverInstalledPluginPackageContent: () => ({ packageNames: [], + packages: [], manifestRoots: [], skillRoots: [], tracingIncludes: [], @@ -116,6 +117,7 @@ describe("loadSkill tool", () => { vi.doMock("@/chat/plugins/package-discovery", () => ({ discoverInstalledPluginPackageContent: () => ({ packageNames: [], + packages: [], manifestRoots: [], skillRoots: [], tracingIncludes: [], diff --git a/specs/plugin-manifest.md b/specs/plugin-manifest.md index d351f849e..b8a10c550 100644 --- a/specs/plugin-manifest.md +++ b/specs/plugin-manifest.md @@ -160,7 +160,7 @@ Rules: - Fail startup on validation errors. - No duplicate plugin names. - No duplicate qualified capability tokens. -- No duplicate effective provider egress domains after app-level `PluginConfig` merges. +- No duplicate effective provider egress domains after app-level `PluginCatalogConfig` merges. - `command-env` requires credentials or API headers. - `plugin.yaml` is the enforceable runtime authority; skill prose cannot override it. diff --git a/specs/plugin-runtime.md b/specs/plugin-runtime.md index 8b2eba757..a83015668 100644 --- a/specs/plugin-runtime.md +++ b/specs/plugin-runtime.md @@ -26,15 +26,16 @@ Define how plugin manifests, skills, credentials, and MCP tool catalogs are load ## Discovery And Loading 1. Scan local plugin roots under `plugins/`. -2. Scan explicitly declared package roots from `PluginConfig`. -3. Apply `PluginConfig` manifest overrides. -4. Parse and validate every effective manifest before registering any plugin. -5. Register capabilities, config keys, OAuth config, provider domains, and skill roots. -6. Discover plugin skills later through `getPluginSkillRoots()`. +2. Scan manifest package roots declared by the shared `defineJuniorPlugins(...)` catalog. +3. Register inline manifests from trusted JavaScript plugin definitions. +4. Apply `PluginCatalogConfig` manifest overrides derived from that plugin set. +5. Parse and validate every effective manifest before registering any plugin. +6. Register capabilities, config keys, OAuth config, provider domains, and skill roots. +7. Discover plugin skills later through `getPluginSkillRoots()`. Plugin registry initialization is synchronous at module load so `discoverSkills()` can associate plugin-backed skills with their parent plugin. -Plugin packages must be explicitly declared in app `PluginConfig`. Runtime must never scan `node_modules`, `package.json` dependencies, or arbitrary filesystem paths to auto-discover plugins. +Plugin packages must be explicitly declared by plugin registrations. Runtime must never scan `node_modules`, `package.json` dependencies, or arbitrary filesystem paths to auto-discover plugins. ## Registry Surface @@ -115,7 +116,11 @@ Plugin-backed skills may explain provider commands, MCP tools, command env, conf Trusted agent behavior is initialized from app code, not `plugin.yaml`. -Apps pass trusted plugin factories to `createApp({ plugins })`, and `juniorNitro({ plugins })` owns build-time copying of bundled plugin content. +Apps pass one `defineJuniorPlugins(...)` set to `juniorNitro({ plugins })` and +`createApp({ plugins })`. `juniorNitro()` extracts package names for build-time +copying, while `createApp()` extracts trusted hooks and validates that every +registration has a matching manifest. Trusted factories carry their manifest +inline, so runtime code is not declared from `plugin.yaml`. Hook contexts expose narrow capabilities rather than raw Junior internals. Trusted plugin hook contracts are defined in [Trusted Plugin Heartbeat Spec](./trusted-plugin-heartbeat.md) and [Trusted Plugin Dispatch Spec](./trusted-plugin-dispatch.md). diff --git a/specs/plugin.md b/specs/plugin.md index 702553d48..6a379e438 100644 --- a/specs/plugin.md +++ b/specs/plugin.md @@ -24,14 +24,15 @@ Define the plugin model for provider integrations. Plugins package declarative r ## Core Model -1. A plugin is either a local `plugins//plugin.yaml` directory or an explicitly declared package that contains `plugin.yaml`, `plugins/`, or `skills/`. +1. A plugin is either a local `plugins//plugin.yaml` directory, an explicitly declared manifest package, or a JavaScript registration returned by `defineJuniorPlugin({ manifest, hooks })`. 2. Plugin discovery is explicit. Runtime must not scan `node_modules`, `package.json` dependencies, or arbitrary filesystem paths to find plugins. 3. `plugin.yaml` owns runtime setup: provider domains, credentials, API headers, command env, runtime dependencies, postinstall commands, OAuth, MCP endpoints, config keys, and skill roots. 4. Skills consume plugin-provided runtime surfaces. They must not tell the agent to install CLIs, bootstrap package managers, configure credentials, repair sandbox packages, or create MCP server config. 5. Credential delivery is host-owned and requester-bound. Real provider secrets never enter sandbox env vars, files, command args, skill text, model-visible tool args, or logs. 6. Plugin-declared MCP tools are host-managed and activated only after a skill from the same plugin is loaded or the model explicitly requests that provider through the MCP bridge tools. -7. Trusted runtime behavior is app-code registration, not manifest registration. Apps pass trusted `JuniorPlugin` objects to `createApp({ plugins })`. -8. Core prompt text must stay plugin-agnostic. Plugin-specific behavior reaches the model through skill descriptions/bodies, tool descriptions, schemas, `promptSnippet`, `promptGuidelines`, and searched MCP descriptors. +7. Trusted runtime behavior is app-code registration, not manifest registration. Apps pass one `defineJuniorPlugins(...)` set to both `juniorNitro({ plugins })` and `createApp({ plugins })`. +8. A package uses one definition source: `plugin.yaml` for declarative plugins, or a JavaScript factory with an inline manifest for trusted plugins. Do not split one plugin definition across both. +9. Core prompt text must stay plugin-agnostic. Plugin-specific behavior reaches the model through skill descriptions/bodies, tool descriptions, schemas, `promptSnippet`, `promptGuidelines`, and searched MCP descriptors. ## File Shape From 84d58051e7252ca35ffcd9f9a86b8e70a1a062ba Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 31 May 2026 10:49:33 -0700 Subject: [PATCH 2/9] feat(plugins): Load plugin sets from Nitro modules Let juniorNitro point at a runtime-safe plugin module so apps only declare enabled plugins once. createApp now reads that module through #junior/config while preserving explicit package enablement. This keeps trusted hook functions available at runtime without importing nitro.config.ts into the server bundle. Refs GH-453 Co-Authored-By: GPT-5 Codex --- apps/example/README.md | 4 +- apps/example/nitro.config.ts | 3 +- apps/example/plugins.ts | 2 +- apps/example/server.ts | 2 - .../content/docs/extend/_plugin-template.md | 3 +- .../docs/extend/agent-browser-plugin.md | 3 +- .../src/content/docs/extend/build-a-plugin.md | 23 +-- .../src/content/docs/extend/datadog-plugin.md | 3 +- .../src/content/docs/extend/github-plugin.md | 3 +- .../src/content/docs/extend/hex-plugin.md | 3 +- .../docs/src/content/docs/extend/index.md | 19 +- .../src/content/docs/extend/linear-plugin.md | 3 +- .../src/content/docs/extend/notion-plugin.md | 3 +- .../content/docs/extend/scheduler-plugin.md | 3 +- .../src/content/docs/extend/sentry-plugin.md | 3 +- .../docs/reference/api/functions/createApp.md | 2 +- .../api/functions/defineJuniorPlugins.md | 2 +- .../reference/api/functions/juniorNitro.md | 2 +- .../api/interfaces/JuniorAppOptions.md | 2 +- .../api/interfaces/JuniorNitroOptions.md | 14 +- .../api/interfaces/JuniorPluginSet.md | 8 +- .../api/interfaces/JuniorPluginSetOptions.md | 4 +- .../api/type-aliases/JuniorPluginInput.md | 2 +- .../content/docs/start-here/existing-app.md | 10 +- .../src/content/docs/start-here/quickstart.md | 17 +- packages/junior-agent-browser/README.md | 3 +- packages/junior-datadog/README.md | 3 +- packages/junior-github/README.md | 3 +- packages/junior-hex/README.md | 3 +- packages/junior-linear/README.md | 3 +- packages/junior-notion/README.md | 3 +- packages/junior-sentry/README.md | 3 +- packages/junior/README.md | 9 +- .../skills/junior/references/packaging.md | 10 +- .../validation-and-troubleshooting.md | 32 ++-- packages/junior/src/app.ts | 26 ++- packages/junior/src/build/virtual-config.ts | 59 +++++- packages/junior/src/cli/check.ts | 4 +- packages/junior/src/nitro.ts | 177 +++++++++++++++++- packages/junior/src/virtual-modules.d.ts | 2 + .../example-build-discovery.test.ts | 31 +-- packages/junior/tests/unit/app-config.test.ts | 40 +++- .../unit/build/nitro-plugin-module.test.ts | 72 +++++++ .../tests/unit/build/virtual-config.test.ts | 41 ++++ specs/dashboard.md | 17 +- specs/plugin-runtime.md | 12 +- specs/plugin.md | 2 +- specs/trusted-plugin-heartbeat.md | 5 +- 48 files changed, 525 insertions(+), 178 deletions(-) create mode 100644 packages/junior/tests/unit/build/nitro-plugin-module.test.ts create mode 100644 packages/junior/tests/unit/build/virtual-config.test.ts diff --git a/apps/example/README.md b/apps/example/README.md index 9a64ddd97..ca07a6f4a 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -39,6 +39,6 @@ Copy `.env.example` and set: ## Wiring - `plugins.ts` is the single source of truth for installed plugin registrations and trusted runtime plugins in this app -- `nitro.config.ts` passes that set to `juniorNitro()` so plugin content is copied into the build output -- `server.ts` passes the same set to `createApp()` so local dev and deployed builds use one plugin contract +- `nitro.config.ts` points `juniorNitro()` at `./plugins` so plugin content is copied into the build output and exposed to runtime through the virtual config module +- `server.ts` calls `createApp()` without repeating the plugin list - root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used for trusted plugin heartbeats and stale dispatch recovery; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover dispatched runs diff --git a/apps/example/nitro.config.ts b/apps/example/nitro.config.ts index 20a68abce..33d60f711 100644 --- a/apps/example/nitro.config.ts +++ b/apps/example/nitro.config.ts @@ -1,12 +1,11 @@ import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { examplePlugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: examplePlugins, + plugins: "./plugins", }), ], routes: { diff --git a/apps/example/plugins.ts b/apps/example/plugins.ts index 6fe63e438..d2e85481e 100644 --- a/apps/example/plugins.ts +++ b/apps/example/plugins.ts @@ -3,7 +3,7 @@ import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; import { githubPlugin } from "@sentry/junior-github"; import { exampleDashboardAuthRequired } from "./dashboard"; -export const examplePlugins = defineJuniorPlugins([ +export const plugins = defineJuniorPlugins([ juniorDashboardPlugin({ authRequired: exampleDashboardAuthRequired(), allowedGoogleDomains: ["sentry.io"], diff --git a/apps/example/server.ts b/apps/example/server.ts index c03519988..68312fc90 100644 --- a/apps/example/server.ts +++ b/apps/example/server.ts @@ -1,11 +1,9 @@ import { createApp } from "@sentry/junior"; import { initSentry } from "@sentry/junior/instrumentation"; -import { examplePlugins } from "./plugins"; initSentry(); const app = await createApp({ - plugins: examplePlugins, configDefaults: { "sentry.org": "sentry", }, diff --git a/packages/docs/src/content/docs/extend/_plugin-template.md b/packages/docs/src/content/docs/extend/_plugin-template.md index 0cab90715..59730c06e 100644 --- a/packages/docs/src/content/docs/extend/_plugin-template.md +++ b/packages/docs/src/content/docs/extend/_plugin-template.md @@ -21,8 +21,7 @@ pnpm add @sentry/junior @sentry/junior-example ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/extend/agent-browser-plugin.md b/packages/docs/src/content/docs/extend/agent-browser-plugin.md index 88cd903d1..ba846dcc8 100644 --- a/packages/docs/src/content/docs/extend/agent-browser-plugin.md +++ b/packages/docs/src/content/docs/extend/agent-browser-plugin.md @@ -22,8 +22,7 @@ pnpm add @sentry/junior @sentry/junior-agent-browser ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/extend/build-a-plugin.md b/packages/docs/src/content/docs/extend/build-a-plugin.md index d7aa8a885..f04e7938c 100644 --- a/packages/docs/src/content/docs/extend/build-a-plugin.md +++ b/packages/docs/src/content/docs/extend/build-a-plugin.md @@ -101,7 +101,7 @@ Junior merges runtime dependency declarations from all loaded plugins and prepar ## Register the package Install the plugin next to `@sentry/junior`, then add the package name to a -shared plugin set: +runtime-safe plugin set: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; @@ -109,9 +109,10 @@ import { defineJuniorPlugins } from "@sentry/junior"; export const plugins = defineJuniorPlugins(["@acme/junior-my-provider"]); ``` -Pass the same `plugins` value to `juniorNitro({ plugins })` and -`createApp({ plugins })`. Do not use the removed `pluginPackages` or -`plugins.packages` options; `junior check` rejects both. +Point `juniorNitro({ plugins: "./plugins" })` at that module and let +`createApp()` read the enabled set from Nitro's virtual module. Do not use the +removed `pluginPackages` or `plugins.packages` options; `junior check` rejects +both. ## Add trusted runtime hooks @@ -159,17 +160,13 @@ both the manifest surface and the trusted hooks. If the same package also ships `skills/`, add `packageName: "@acme/junior-my-provider"` so Nitro copies those skills into the deployment bundle. -Register the trusted plugin from the app: +Enable the trusted plugin from the app plugin module: -```ts title="server.ts" -import { createApp } from "@sentry/junior"; -import { plugins } from "./plugins"; - -const app = await createApp({ - plugins, -}); +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; +import { myProviderPlugin } from "@acme/junior-my-provider"; -export default app; +export const plugins = defineJuniorPlugins([myProviderPlugin()]); ``` Use `ctx.decision.replaceInput(...)` only with object-shaped tool input. Junior diff --git a/packages/docs/src/content/docs/extend/datadog-plugin.md b/packages/docs/src/content/docs/extend/datadog-plugin.md index 1e4a3f4ba..0835a1814 100644 --- a/packages/docs/src/content/docs/extend/datadog-plugin.md +++ b/packages/docs/src/content/docs/extend/datadog-plugin.md @@ -25,8 +25,7 @@ pnpm add @sentry/junior @sentry/junior-datadog ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/extend/github-plugin.md b/packages/docs/src/content/docs/extend/github-plugin.md index 07c8de7b3..5051d298a 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -21,8 +21,7 @@ pnpm add @sentry/junior @sentry/junior-github ## Runtime setup -Add the trusted plugin factory to the shared plugin set used by both -`juniorNitro()` and `createApp()`. The factory registers the GitHub manifest, +Add the trusted plugin factory to the plugin set exported from `plugins.ts`. The factory registers the GitHub manifest, bundled skills, and Git commit attribution hooks together. ```ts title="plugins.ts" diff --git a/packages/docs/src/content/docs/extend/hex-plugin.md b/packages/docs/src/content/docs/extend/hex-plugin.md index 8a337b98f..78d81e7cf 100644 --- a/packages/docs/src/content/docs/extend/hex-plugin.md +++ b/packages/docs/src/content/docs/extend/hex-plugin.md @@ -25,8 +25,7 @@ pnpm add @sentry/junior @sentry/junior-hex ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index 8cf810b90..2117c8bfd 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -59,10 +59,11 @@ For reuse across apps or teams, package plugin manifests and any bundled skills pnpm add @sentry/junior @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-scheduler @sentry/junior-sentry @sentry/junior-vercel ``` -Create one shared plugin set and pass it to both `juniorNitro()` and -`createApp()`. Manifest-only packages use package-name strings. Plugins that -need trusted runtime hooks use JavaScript factories such as `githubPlugin()` -and `schedulerPlugin()`. +Create one runtime-safe plugin set and point `juniorNitro()` at that module. +Manifest-only packages use package-name strings. Plugins that need trusted +runtime hooks use JavaScript factories such as `githubPlugin()` and +`schedulerPlugin()`. `createApp()` reads the same enabled plugin set from +Nitro's virtual module at runtime. ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; @@ -87,13 +88,12 @@ export const plugins = defineJuniorPlugins([ ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { plugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins, + plugins: "./plugins", }), ], routes: { @@ -104,9 +104,8 @@ export default defineConfig({ ```ts title="server.ts" import { createApp } from "@sentry/junior"; -import { plugins } from "./plugins"; -const app = await createApp({ plugins }); +const app = await createApp(); export default app; ``` @@ -344,8 +343,8 @@ Then install it in the host app: pnpm add @acme/junior-example ``` -Add the package name to `defineJuniorPlugins(...)`, then pass the same plugin -set to `juniorNitro({ plugins })` and `createApp({ plugins })`. +Add the package name to `defineJuniorPlugins(...)`, then point +`juniorNitro({ plugins: "./plugins" })` at that module. ## Validate extensions diff --git a/packages/docs/src/content/docs/extend/linear-plugin.md b/packages/docs/src/content/docs/extend/linear-plugin.md index 01cd3fe86..4e70977da 100644 --- a/packages/docs/src/content/docs/extend/linear-plugin.md +++ b/packages/docs/src/content/docs/extend/linear-plugin.md @@ -23,8 +23,7 @@ pnpm add @sentry/junior @sentry/junior-linear ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/extend/notion-plugin.md b/packages/docs/src/content/docs/extend/notion-plugin.md index 568ff437c..6c83bfbe6 100644 --- a/packages/docs/src/content/docs/extend/notion-plugin.md +++ b/packages/docs/src/content/docs/extend/notion-plugin.md @@ -25,8 +25,7 @@ pnpm add @sentry/junior @sentry/junior-notion ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/extend/scheduler-plugin.md b/packages/docs/src/content/docs/extend/scheduler-plugin.md index d57a5eea9..38cdb4f3b 100644 --- a/packages/docs/src/content/docs/extend/scheduler-plugin.md +++ b/packages/docs/src/content/docs/extend/scheduler-plugin.md @@ -22,8 +22,7 @@ Install the package next to `@sentry/junior`: pnpm add @sentry/junior-scheduler ``` -Add the trusted plugin factory to the shared plugin set used by both -`juniorNitro()` and `createApp()`. The factory registers the scheduler +Add the trusted plugin factory to the plugin set exported from `plugins.ts`. The factory registers the scheduler manifest, schedule-management tools, and heartbeat behavior together. ```ts title="plugins.ts" diff --git a/packages/docs/src/content/docs/extend/sentry-plugin.md b/packages/docs/src/content/docs/extend/sentry-plugin.md index 730f31161..4edfdb2c2 100644 --- a/packages/docs/src/content/docs/extend/sentry-plugin.md +++ b/packages/docs/src/content/docs/extend/sentry-plugin.md @@ -23,8 +23,7 @@ pnpm add @sentry/junior @sentry/junior-sentry ## Runtime setup -Add the package name to the shared plugin set used by both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/docs/src/content/docs/reference/api/functions/createApp.md b/packages/docs/src/content/docs/reference/api/functions/createApp.md index 2372aa7b0..38bfb4ae5 100644 --- a/packages/docs/src/content/docs/reference/api/functions/createApp.md +++ b/packages/docs/src/content/docs/reference/api/functions/createApp.md @@ -7,7 +7,7 @@ title: "createApp" > **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\> -Defined in: [app.ts:228](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L228) +Defined in: [app.ts:224](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L224) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md b/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md index acbb32b18..98362a54f 100644 --- a/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md +++ b/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md @@ -7,7 +7,7 @@ title: "defineJuniorPlugins" > **defineJuniorPlugins**(`inputs`, `options?`): [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) -Defined in: plugins.ts:102 +Defined in: [plugins.ts:102](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L102) Define package-name plugins and JS plugin definitions for one app. diff --git a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md index 80f52d6e5..9aae74c38 100644 --- a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md +++ b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md @@ -7,7 +7,7 @@ title: "juniorNitro" > **juniorNitro**(`options?`): `object` -Defined in: [nitro.ts:30](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L30) +Defined in: [nitro.ts:170](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L170) Nitro module that copies app and plugin content into the Vercel build output. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index f8c494154..96ad6d67f 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md @@ -25,7 +25,7 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p Defined in: [app.ts:47](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L47) -Plugin package names and JS definitions shared with `juniorNitro()`. +Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. *** diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md index 996be00e4..6738f6338 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorNitroOptions" --- -Defined in: [nitro.ts:15](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L15) +Defined in: [nitro.ts:33](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L33) ## Properties @@ -13,7 +13,7 @@ Defined in: [nitro.ts:15](https://github.com/getsentry/junior/blob/main/packages > `optional` **cwd?**: `string` -Defined in: [nitro.ts:16](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L16) +Defined in: [nitro.ts:34](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L34) *** @@ -21,7 +21,7 @@ Defined in: [nitro.ts:16](https://github.com/getsentry/junior/blob/main/packages > `optional` **includeFiles?**: `string`[] -Defined in: [nitro.ts:26](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L26) +Defined in: [nitro.ts:44](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L44) Extra file patterns to copy into the server output for files that the bundler cannot trace (e.g. dynamically imported providers). @@ -34,14 +34,14 @@ module resolution. Example: `"@earendil-works/pi-ai/dist/providers/*.js"` > `optional` **maxDuration?**: `number` -Defined in: [nitro.ts:17](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L17) +Defined in: [nitro.ts:35](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L35) *** ### plugins? -> `optional` **plugins?**: [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) +> `optional` **plugins?**: `JuniorNitroPluginSource` -Defined in: [nitro.ts:19](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L19) +Defined in: [nitro.ts:37](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L37) -Plugin package names and JS definitions bundled into the app. Pass the same set to `createApp()`. +Plugin set or runtime-safe plugin module bundled into the app. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md index 71fe1a390..b16c5bc33 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSet.md @@ -5,7 +5,7 @@ prev: false title: "JuniorPluginSet" --- -Defined in: plugins.ts:16 +Defined in: [plugins.ts:16](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L16) Reusable plugin registrations and manifest overrides. @@ -15,7 +15,7 @@ Reusable plugin registrations and manifest overrides. > `optional` **manifests?**: `Record`\<`string`, `PluginManifestConfig`\> -Defined in: plugins.ts:18 +Defined in: [plugins.ts:18](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L18) Install-level manifest overrides applied before validation. @@ -25,7 +25,7 @@ Install-level manifest overrides applied before validation. > **packageNames**: `string`[] -Defined in: plugins.ts:20 +Defined in: [plugins.ts:20](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L20) Manifest-only plugin packages included by package name. @@ -35,6 +35,6 @@ Manifest-only plugin packages included by package name. > **registrations**: `JuniorPluginRegistration`[] -Defined in: plugins.ts:22 +Defined in: [plugins.ts:22](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L22) JavaScript plugin definitions included by package factories. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md index 1e68cc04b..61a252d28 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorPluginSetOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorPluginSetOptions" --- -Defined in: plugins.ts:10 +Defined in: [plugins.ts:10](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L10) ## Properties @@ -13,6 +13,6 @@ Defined in: plugins.ts:10 > `optional` **manifests?**: `Record`\<`string`, `PluginManifestConfig`\> -Defined in: plugins.ts:12 +Defined in: [plugins.ts:12](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L12) Install-level manifest overrides applied before validation. diff --git a/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md b/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md index aa7012d85..ae5ab70b1 100644 --- a/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md +++ b/packages/docs/src/content/docs/reference/api/type-aliases/JuniorPluginInput.md @@ -7,4 +7,4 @@ title: "JuniorPluginInput" > **JuniorPluginInput** = `JuniorPluginRegistration` \| `string` -Defined in: plugins.ts:8 +Defined in: [plugins.ts:8](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L8) diff --git a/packages/docs/src/content/docs/start-here/existing-app.md b/packages/docs/src/content/docs/start-here/existing-app.md index c766a6927..a9ee46842 100644 --- a/packages/docs/src/content/docs/start-here/existing-app.md +++ b/packages/docs/src/content/docs/start-here/existing-app.md @@ -24,9 +24,8 @@ import { initSentry } from "@sentry/junior/instrumentation"; initSentry(); import { createApp } from "@sentry/junior"; -import { plugins } from "./plugins"; -const app = await createApp({ plugins }); +const app = await createApp(); export default app; ``` @@ -35,8 +34,8 @@ export default app; ## Add Nitro wiring -Create a shared plugin set and register `juniorNitro()` so app files and -declared plugin packages are copied into the deployment bundle: +Create a runtime-safe plugin set and point `juniorNitro()` at it so app files +and declared plugin packages are copied into the deployment bundle: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; @@ -47,12 +46,11 @@ export const plugins = defineJuniorPlugins(["@sentry/junior-sentry"]); ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { plugins } from "./plugins"; export default defineConfig({ modules: [ juniorNitro({ - plugins, + plugins: "./plugins", }), ], routes: { diff --git a/packages/docs/src/content/docs/start-here/quickstart.md b/packages/docs/src/content/docs/start-here/quickstart.md index d7d967088..7a0ee03d6 100644 --- a/packages/docs/src/content/docs/start-here/quickstart.md +++ b/packages/docs/src/content/docs/start-here/quickstart.md @@ -95,7 +95,9 @@ After you complete [Slack App Setup](/start-here/slack-app-setup/), point Slack ## Add packaged plugins -Packaged plugins must be installed and listed in `juniorNitro` so Nitro bundles their manifests, skills, and runtime dependencies. +Packaged plugins must be installed and explicitly listed in the plugin set +referenced by `juniorNitro` so Nitro bundles their manifests, skills, hooks, +and runtime dependencies. Install only the plugins you plan to enable: @@ -103,7 +105,7 @@ Install only the plugins you plan to enable: pnpm add @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-scheduler @sentry/junior-sentry @sentry/junior-vercel ``` -Then create one shared plugin set: +Then create one runtime-safe plugin set: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; @@ -125,18 +127,18 @@ export const plugins = defineJuniorPlugins([ ]); ``` -Pass that same `plugins` value to `juniorNitro()` and `createApp()`: +Point `juniorNitro()` at that module. `createApp()` reads the same plugin set +from Nitro's virtual module, so the server entry does not repeat it: ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { plugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins, + plugins: "./plugins", }), ], routes: { @@ -147,9 +149,8 @@ export default defineConfig({ ```ts title="server.ts" import { createApp } from "@sentry/junior"; -import { plugins } from "./plugins"; -const app = await createApp({ plugins }); +const app = await createApp(); export default app; ``` @@ -160,7 +161,7 @@ Run the app check after changing plugins or skills: pnpm check ``` -The shared plugin set is also where trusted runtime hooks are registered. +The runtime-safe plugin set is also where trusted runtime hooks are registered. `schedulerPlugin()` enables scheduled task tools and heartbeat behavior, and `githubPlugin()` enforces Git commit attribution. See [Scheduler Plugin](/extend/scheduler-plugin/) and diff --git a/packages/junior-agent-browser/README.md b/packages/junior-agent-browser/README.md index 7e694e31f..c9afc1953 100644 --- a/packages/junior-agent-browser/README.md +++ b/packages/junior-agent-browser/README.md @@ -8,8 +8,7 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-agent-browser ``` -Add the package name to the shared plugin set passed to both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior-datadog/README.md b/packages/junior-datadog/README.md index 1e78fd6bf..7c77c629d 100644 --- a/packages/junior-datadog/README.md +++ b/packages/junior-datadog/README.md @@ -8,8 +8,7 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-datadog ``` -Then add the package name to the shared plugin set passed to both -`juniorNitro()` and `createApp()`: +Then add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior-github/README.md b/packages/junior-github/README.md index 079fb0e8a..072fbe6a2 100644 --- a/packages/junior-github/README.md +++ b/packages/junior-github/README.md @@ -8,8 +8,7 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-github ``` -Add the plugin factory to the shared plugin set passed to both `juniorNitro()` -and `createApp()`: +Add the plugin factory to the plugin set exported from `plugins.ts`: ```ts import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior-hex/README.md b/packages/junior-hex/README.md index 89114bc72..35802b3e2 100644 --- a/packages/junior-hex/README.md +++ b/packages/junior-hex/README.md @@ -10,8 +10,7 @@ pnpm add @sentry/junior @sentry/junior-hex ## Configure -Add the package name to the shared plugin set passed to both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior-linear/README.md b/packages/junior-linear/README.md index 76e2392ba..0ce78225d 100644 --- a/packages/junior-linear/README.md +++ b/packages/junior-linear/README.md @@ -8,8 +8,7 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-linear ``` -Then add the package name to the shared plugin set passed to both -`juniorNitro()` and `createApp()`: +Then add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior-notion/README.md b/packages/junior-notion/README.md index 46c381f55..d99b67c8b 100644 --- a/packages/junior-notion/README.md +++ b/packages/junior-notion/README.md @@ -8,8 +8,7 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-notion ``` -Then add the package name to the shared plugin set passed to both -`juniorNitro()` and `createApp()`: +Then add the package name to the plugin set exported from `plugins.ts`: ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior-sentry/README.md b/packages/junior-sentry/README.md index ee7fcff45..b71e3178b 100644 --- a/packages/junior-sentry/README.md +++ b/packages/junior-sentry/README.md @@ -8,8 +8,7 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-sentry ``` -Add the package name to the shared plugin set passed to both `juniorNitro()` -and `createApp()`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts import { defineJuniorPlugins } from "@sentry/junior"; diff --git a/packages/junior/README.md b/packages/junior/README.md index 6cba32832..3d7a67c9a 100644 --- a/packages/junior/README.md +++ b/packages/junior/README.md @@ -25,10 +25,11 @@ export default app; Run `junior init my-bot` to scaffold a complete project including `vercel.json` for Vercel deployment. -Use `defineJuniorPlugins([...])` to create one plugin set, then pass it to both -`juniorNitro({ plugins })` and `createApp({ plugins })`. Manifest-only packages -use package-name strings; trusted factories such as `githubPlugin()` register -their manifest and in-process hooks together. +Use `defineJuniorPlugins([...])` in a runtime-safe plugin module, then point +`juniorNitro({ plugins: "./plugins" })` at that module. `createApp()` reads the +same enabled set from Nitro's virtual module. Manifest-only packages use +package-name strings; trusted factories such as `githubPlugin()` register their +manifest and in-process hooks together. ## Full docs diff --git a/packages/junior/skills/junior/references/packaging.md b/packages/junior/skills/junior/references/packaging.md index e1b3c41c9..4dc6c2863 100644 --- a/packages/junior/skills/junior/references/packaging.md +++ b/packages/junior/skills/junior/references/packaging.md @@ -32,7 +32,7 @@ my-junior-plugin/ ## Host app wiring -Install next to `@sentry/junior`, then export a shared plugin set. +Install next to `@sentry/junior`, then export a runtime-safe plugin set. ```ts import { defineJuniorPlugins } from "@sentry/junior"; @@ -40,18 +40,18 @@ import { defineJuniorPlugins } from "@sentry/junior"; export const plugins = defineJuniorPlugins(["@acme/junior-my-provider"]); ``` -Pass the same set to `juniorNitro()` and `createApp()`. +Point `juniorNitro()` at the plugin module. `createApp()` reads that enabled +set from Nitro's virtual module. ```ts import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { plugins } from "./plugins"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins, + plugins: "./plugins", }), ], routes: { @@ -61,7 +61,7 @@ export default defineConfig({ ``` ```ts -const app = await createApp({ plugins }); +const app = await createApp(); ``` Packages that export trusted runtime hooks must be registered from app code with diff --git a/packages/junior/skills/junior/references/validation-and-troubleshooting.md b/packages/junior/skills/junior/references/validation-and-troubleshooting.md index 9038ce45a..c4fdadb6d 100644 --- a/packages/junior/skills/junior/references/validation-and-troubleshooting.md +++ b/packages/junior/skills/junior/references/validation-and-troubleshooting.md @@ -31,22 +31,22 @@ For packaged plugins, load a configured host app or add a parser test. ## Common failures -| Symptom | Likely cause | Fix | -| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `name must match directory` | Frontmatter `name` differs from folder name. | Rename the folder or the skill `name`. | -| `duplicate skill name` | App and plugin skill roots contain the same skill name. | Rename one skill and adjust trigger language. | -| `requires-capabilities is no longer supported` | Old skill-level auth metadata. | Move capabilities to `plugin.yaml`. | -| `uses-config is no longer supported` | Old skill-level config metadata. | Move config keys to `plugin.yaml`. | -| `skill instructions must not hardcode harness tool-discovery or MCP dispatcher mechanics` | Skill prose names internal dispatcher APIs or active catalog tags. | Describe provider actions in domain terms instead. | -| `api-headers requires domains` | Manifest declares headers without target domains. | Add valid `domains` or remove `api-headers`. | -| `domains requires api-headers` | Manifest declares top-level domains without headers. | Add headers or remove top-level domains. | -| `oauth requires credentials` | OAuth block has no credential delivery config. | Add `credentials.type: oauth-bearer`. | -| `oauth requires credentials.type "oauth-bearer"` | OAuth was paired with unsupported credentials. | Use bearer OAuth credentials or remove `oauth`. | -| `mcp.url references env var ... not declared` | Placeholder is not listed in `env-vars`. | Declare the env var and optional default where allowed. | -| `API header env vars must not declare defaults` | Secret-like header env var has a default. | Remove the default and set the value in deployment env. | -| `target.config-key ... must be listed in config-keys` | Target points at undeclared config. | Add the short config key to `config-keys`. | -| Plugin does not load in app | Package installed but it is missing from `defineJuniorPlugins(...)`, or local files are outside `app/plugins`. | Add the package name or trusted factory to the shared plugin set, or move files under `app/plugins`. | -| Skill does not show up | Skill is in a legacy root, has invalid frontmatter, or duplicates another skill name. | Move under `app/skills` or plugin `skills`, then rerun validation. | +| Symptom | Likely cause | Fix | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `name must match directory` | Frontmatter `name` differs from folder name. | Rename the folder or the skill `name`. | +| `duplicate skill name` | App and plugin skill roots contain the same skill name. | Rename one skill and adjust trigger language. | +| `requires-capabilities is no longer supported` | Old skill-level auth metadata. | Move capabilities to `plugin.yaml`. | +| `uses-config is no longer supported` | Old skill-level config metadata. | Move config keys to `plugin.yaml`. | +| `skill instructions must not hardcode harness tool-discovery or MCP dispatcher mechanics` | Skill prose names internal dispatcher APIs or active catalog tags. | Describe provider actions in domain terms instead. | +| `api-headers requires domains` | Manifest declares headers without target domains. | Add valid `domains` or remove `api-headers`. | +| `domains requires api-headers` | Manifest declares top-level domains without headers. | Add headers or remove top-level domains. | +| `oauth requires credentials` | OAuth block has no credential delivery config. | Add `credentials.type: oauth-bearer`. | +| `oauth requires credentials.type "oauth-bearer"` | OAuth was paired with unsupported credentials. | Use bearer OAuth credentials or remove `oauth`. | +| `mcp.url references env var ... not declared` | Placeholder is not listed in `env-vars`. | Declare the env var and optional default where allowed. | +| `API header env vars must not declare defaults` | Secret-like header env var has a default. | Remove the default and set the value in deployment env. | +| `target.config-key ... must be listed in config-keys` | Target points at undeclared config. | Add the short config key to `config-keys`. | +| Plugin does not load in app | Package installed but it is missing from `defineJuniorPlugins(...)`, or local files are outside `app/plugins`. | Add the package name or trusted factory to the runtime-safe plugin set, or move files under `app/plugins`. | +| Skill does not show up | Skill is in a legacy root, has invalid frontmatter, or duplicates another skill name. | Move under `app/skills` or plugin `skills`, then rerun validation. | ## Runtime verification diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index ef3d508cb..a626b2786 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -48,12 +48,13 @@ export type { export interface JuniorAppOptions { /** Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. */ configDefaults?: Record; - /** Plugin package names and JS definitions shared with `juniorNitro()`. */ + /** Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. */ plugins?: JuniorPluginSet; waitUntil?: WaitUntilFn; } interface JuniorVirtualConfig { + pluginSet?: JuniorPluginSet; plugins?: PluginCatalogConfig; trustedPluginRegistrations: string[]; } @@ -81,10 +82,12 @@ async function resolveVirtualConfig(): Promise< > { try { const mod: { + pluginSet?: JuniorPluginSet; plugins?: PluginCatalogConfig; trustedPluginRegistrations?: string[]; } = await import("#junior/config"); return { + pluginSet: mod.pluginSet, plugins: mod.plugins, trustedPluginRegistrations: mod.trustedPluginRegistrations ?? [], }; @@ -96,15 +99,8 @@ async function resolveVirtualConfig(): Promise< } } -/** Resolve plugin configuration from the virtual module, falling back to env. */ -async function resolveBuildPluginCatalogConfig(): Promise< - PluginCatalogConfig | undefined -> { - const virtualConfig = await resolveVirtualConfig(); - if (virtualConfig?.plugins) { - return virtualConfig.plugins; - } - +/** Resolve plugin configuration from the env fallback. */ +function resolveEnvPluginCatalogConfig(): PluginCatalogConfig | undefined { const packages = readEnvPluginPackages(); if (packages) { return { packages }; @@ -185,7 +181,7 @@ function validateBuildIncludesPluginPackages( return; } throw new Error( - `createApp() registered plugin package(s) not bundled by juniorNitro(): ${missing.join(", ")}. Pass the same defineJuniorPlugins(...) set to juniorNitro({ plugins }) and createApp({ plugins }).`, + `createApp() registered plugin package(s) not bundled by juniorNitro(): ${missing.join(", ")}. Point juniorNitro({ plugins: "./plugins" }) at the runtime plugin module or pass the same defineJuniorPlugins(...) set to juniorNitro({ plugins }) and createApp({ plugins }).`, ); } @@ -208,7 +204,7 @@ function validateBuildIncludesTrustedRegistrations( } throw new Error( - `createApp() is missing trusted plugin registration(s) bundled by juniorNitro(): ${missing.join(", ")}. Pass the same defineJuniorPlugins(...) set to juniorNitro({ plugins }) and createApp({ plugins }).`, + `createApp() is missing trusted plugin registration(s) bundled by juniorNitro(): ${missing.join(", ")}. Pass a runtime-safe plugin module to juniorNitro({ plugins: "./plugins" }) or pass the same defineJuniorPlugins(...) set to createApp({ plugins }).`, ); } @@ -254,13 +250,13 @@ function mountAgentPluginRoutes( /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { - const configuredPlugins = options?.plugins; + const virtualConfig = await resolveVirtualConfig(); + const configuredPlugins = options?.plugins ?? virtualConfig?.pluginSet; const agentPlugins = trustedPluginRegistrationsFromPluginSet(configuredPlugins); - const virtualConfig = await resolveVirtualConfig(); const pluginConfig = configuredPlugins ? pluginCatalogConfigFromPluginSet(configuredPlugins) - : (virtualConfig?.plugins ?? (await resolveBuildPluginCatalogConfig())); + : (virtualConfig?.plugins ?? resolveEnvPluginCatalogConfig()); if (configuredPlugins) { validateBuildIncludesPluginPackages(pluginConfig, virtualConfig); } diff --git a/packages/junior/src/build/virtual-config.ts b/packages/junior/src/build/virtual-config.ts index 6fbde235b..3dd313ecf 100644 --- a/packages/junior/src/build/virtual-config.ts +++ b/packages/junior/src/build/virtual-config.ts @@ -1,16 +1,67 @@ import type { Nitro } from "nitro/types"; import type { PluginCatalogConfig } from "@/chat/plugins/types"; +import { + pluginCatalogConfigFromPluginSet, + trustedPluginRegistrationsFromPluginSet, + type JuniorPluginSet, +} from "@/plugins"; + +export interface RuntimePluginModule { + exportName: string; + specifier: string; +} + +function renderRuntimePluginImport(module: RuntimePluginModule): string { + if (module.exportName === "default") { + return `import juniorRuntimePluginSet from ${JSON.stringify(module.specifier)};`; + } + + return `import { ${module.exportName} as juniorRuntimePluginSet } from ${JSON.stringify(module.specifier)};`; +} + +/** Render the virtual config module consumed by createApp(). */ +export function renderVirtualConfig(options: { + plugins?: PluginCatalogConfig; + pluginModule?: RuntimePluginModule; + trustedPluginRegistrations?: string[]; +}): string { + const lines = [ + ...(options.pluginModule + ? [ + renderRuntimePluginImport(options.pluginModule), + "export const pluginSet = juniorRuntimePluginSet;", + ] + : ["export const pluginSet = undefined;"]), + `export const plugins = ${JSON.stringify(options.plugins ?? { packages: [] })};`, + `export const trustedPluginRegistrations = ${JSON.stringify(options.trustedPluginRegistrations ?? [])};`, + ]; + + return lines.join("\n"); +} /** Inject a virtual module so createApp() can read the plugin list at runtime. */ export function injectVirtualConfig( nitro: Nitro, options: { + loadPluginSet?: () => Promise; + pluginModule?: RuntimePluginModule; plugins?: PluginCatalogConfig; trustedPluginRegistrations?: string[]; } = {}, ): void { - nitro.options.virtual["#junior/config"] = [ - `export const plugins = ${JSON.stringify(options.plugins ?? { packages: [] })};`, - `export const trustedPluginRegistrations = ${JSON.stringify(options.trustedPluginRegistrations ?? [])};`, - ].join("\n"); + nitro.options.virtual["#junior/config"] = async () => { + if (!options.loadPluginSet) { + return renderVirtualConfig(options); + } + + const pluginSet = await options.loadPluginSet(); + + return renderVirtualConfig({ + pluginModule: options.pluginModule, + plugins: pluginCatalogConfigFromPluginSet(pluginSet), + trustedPluginRegistrations: trustedPluginRegistrationsFromPluginSet( + pluginSet, + ).map((plugin) => plugin.name), + }); + }; } diff --git a/packages/junior/src/cli/check.ts b/packages/junior/src/cli/check.ts index 2e4a880a4..a37013e3f 100644 --- a/packages/junior/src/cli/check.ts +++ b/packages/junior/src/cli/check.ts @@ -573,13 +573,13 @@ async function validateAppSourceFiles( if (/\bpluginPackages\s*:/.test(source)) { errors.push( - `${sourcePath}: pluginPackages is no longer supported. Export a defineJuniorPlugins(...) set and pass it to juniorNitro({ plugins }) and createApp({ plugins }).`, + `${sourcePath}: pluginPackages is no longer supported. Export a defineJuniorPlugins(...) set and point juniorNitro({ plugins: "./plugins" }) at it.`, ); } if (/\bplugins\s*:\s*\{\s*packages\s*:/.test(source)) { errors.push( - `${sourcePath}: plugins.packages is no longer supported. Export a defineJuniorPlugins(...) set and pass it to juniorNitro({ plugins }) and createApp({ plugins }).`, + `${sourcePath}: plugins.packages is no longer supported. Export a defineJuniorPlugins(...) set and point juniorNitro({ plugins: "./plugins" }) at it.`, ); } diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index 4422e40df..db71d4d40 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -1,22 +1,40 @@ import path from "node:path"; +import { statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import type { Nitro } from "nitro/types"; import { applyRolldownTreeshakeWorkaround } from "@/build/rolldown-workarounds"; import { copyAppAndPluginContent, copyIncludedFiles, } from "@/build/copy-build-content"; -import { injectVirtualConfig } from "@/build/virtual-config"; +import { + injectVirtualConfig, + type RuntimePluginModule, +} from "@/build/virtual-config"; import { pluginCatalogConfigFromPluginSet, trustedPluginRegistrationsFromPluginSet, type JuniorPluginSet, } from "@/plugins"; +export interface JuniorPluginModuleReference { + /** Runtime-safe module that exports a `defineJuniorPlugins(...)` set. */ + module: string; + /** Named export to import from `module`. Defaults to `plugins`. */ + exportName?: string; +} + +export type JuniorNitroPluginSource = + | JuniorPluginModuleReference + | JuniorPluginSet + | string; + export interface JuniorNitroOptions { cwd?: string; maxDuration?: number; - /** Plugin package names and JS definitions bundled into the app. Pass the same set to `createApp()`. */ - plugins?: JuniorPluginSet; + /** Plugin set or runtime-safe plugin module bundled into the app. */ + plugins?: JuniorNitroPluginSource; /** * Extra file patterns to copy into the server output for files that the * bundler cannot trace (e.g. dynamically imported providers). @@ -26,6 +44,128 @@ export interface JuniorNitroOptions { includeFiles?: string[]; } +interface ResolvedPluginModuleReference { + exportName: string; + importUrl: string; + runtimeModule: RuntimePluginModule; +} + +const PLUGIN_MODULE_EXTENSIONS = [ + "", + ".ts", + ".tsx", + ".mts", + ".mjs", + ".js", + ".cjs", +]; + +function isPluginModuleReference( + value: JuniorNitroPluginSource | undefined, +): value is JuniorPluginModuleReference | string { + return typeof value === "string" || Boolean(value && "module" in value); +} + +function isPluginSet( + value: JuniorNitroPluginSource | undefined, +): value is JuniorPluginSet { + if (!value || typeof value !== "object") { + return false; + } + + return "packageNames" in value && "registrations" in value; +} + +function resolveRelativePluginModule(cwd: string, specifier: string): string { + const basePath = path.resolve(cwd, specifier); + for (const extension of PLUGIN_MODULE_EXTENSIONS) { + const candidate = `${basePath}${extension}`; + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Try the next extension. + } + } + for (const extension of PLUGIN_MODULE_EXTENSIONS) { + const candidate = path.join(basePath, `index${extension}`); + try { + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Try the next extension. + } + } + + throw new Error(`Plugin module "${specifier}" could not be resolved`); +} + +function resolvePluginModule( + cwd: string, + input: JuniorPluginModuleReference | string, +): ResolvedPluginModuleReference { + const moduleSpecifier = typeof input === "string" ? input : input.module; + const exportName = + typeof input === "string" ? "plugins" : (input.exportName ?? "plugins"); + if (!moduleSpecifier.trim()) { + throw new Error("Plugin module specifier must not be empty"); + } + + if (moduleSpecifier.startsWith(".") || path.isAbsolute(moduleSpecifier)) { + const resolvedPath = resolveRelativePluginModule(cwd, moduleSpecifier); + return { + exportName, + importUrl: pathToFileURL(resolvedPath).href, + runtimeModule: { + exportName, + specifier: resolvedPath.split(path.sep).join("/"), + }, + }; + } + + const requireFromApp = createRequire(path.join(cwd, "package.json")); + const resolvedPath = requireFromApp.resolve(moduleSpecifier); + return { + exportName, + importUrl: pathToFileURL(resolvedPath).href, + runtimeModule: { + exportName, + specifier: moduleSpecifier, + }, + }; +} + +function assertPluginSet(value: unknown, source: string): JuniorPluginSet { + if ( + !value || + typeof value !== "object" || + !Array.isArray((value as Partial).packageNames) || + !Array.isArray((value as Partial).registrations) + ) { + throw new Error( + `Plugin module ${source} must export a defineJuniorPlugins(...) set`, + ); + } + + return value as JuniorPluginSet; +} + +async function loadPluginSetFromModule( + moduleRef: ResolvedPluginModuleReference, +): Promise { + const mod = (await import(moduleRef.importUrl)) as Record; + const value = + moduleRef.exportName === "default" + ? (mod.default as unknown) + : mod[moduleRef.exportName]; + return assertPluginSet( + value, + `${moduleRef.importUrl}#${moduleRef.exportName}`, + ); +} + /** Nitro module that copies app and plugin content into the Vercel build output. */ export function juniorNitro(options: JuniorNitroOptions = {}): { nitro: { setup(nitro: unknown): void }; @@ -43,23 +183,42 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { options.maxDuration ?? 800; applyRolldownTreeshakeWorkaround(nitro); - const pluginCatalogConfig = pluginCatalogConfigFromPluginSet( - options.plugins, - ); + const pluginSource = options.plugins; + const pluginModule = isPluginModuleReference(pluginSource) + ? resolvePluginModule(cwd, pluginSource) + : undefined; + const directPluginSet = isPluginSet(pluginSource) + ? pluginSource + : undefined; + const pluginSetPromise: Promise = + pluginModule + ? loadPluginSetFromModule(pluginModule) + : Promise.resolve(directPluginSet); + const pluginCatalogConfig = + pluginCatalogConfigFromPluginSet(directPluginSet); const trustedPluginRegistrations = - trustedPluginRegistrationsFromPluginSet(options.plugins).map( + trustedPluginRegistrationsFromPluginSet(directPluginSet).map( (plugin) => plugin.name, ); injectVirtualConfig(nitro, { + ...(pluginModule + ? { + loadPluginSet: () => pluginSetPromise, + pluginModule: pluginModule.runtimeModule, + } + : {}), plugins: pluginCatalogConfig, trustedPluginRegistrations, }); - nitro.hooks.hook("compiled", () => { + nitro.hooks.hook("compiled", async () => { + const pluginSet = await pluginSetPromise; + const compiledPluginCatalogConfig = + pluginCatalogConfigFromPluginSet(pluginSet); copyAppAndPluginContent( cwd, nitro.options.output.serverDir, - pluginCatalogConfig?.packages, + compiledPluginCatalogConfig?.packages, ); copyIncludedFiles( cwd, diff --git a/packages/junior/src/virtual-modules.d.ts b/packages/junior/src/virtual-modules.d.ts index 92dfd2670..48d9c77a3 100644 --- a/packages/junior/src/virtual-modules.d.ts +++ b/packages/junior/src/virtual-modules.d.ts @@ -1,7 +1,9 @@ /** Virtual module injected by juniorNitro() at build time. */ declare module "#junior/config" { import type { PluginCatalogConfig } from "@/chat/plugins/types"; + import type { JuniorPluginSet } from "@/plugins"; + export const pluginSet: JuniorPluginSet | undefined; export const plugins: PluginCatalogConfig; export const trustedPluginRegistrations: string[]; } diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index a630dd46c..b5341b642 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { cpSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { cpSync, realpathSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -10,6 +10,7 @@ const originalCwd = process.cwd(); const repoRoot = path.resolve(import.meta.dirname, "../../../.."); const exampleRoot = path.join(repoRoot, "apps/example"); const exampleEntry = path.join(exampleRoot, "server.ts"); +const examplePluginsModule = path.join(exampleRoot, "plugins.ts"); const exampleDashboardConfig = path.join(exampleRoot, "dashboard.ts"); const exampleRequire = createRequire(exampleEntry); const vercelEnvNames = [ @@ -27,19 +28,21 @@ function isSamePath(left: string, right: string): boolean { } } -function getExamplePluginPackages(): string[] { - const pkg = JSON.parse( - readFileSync(path.join(exampleRoot, "package.json"), "utf8"), - ) as { - dependencies?: Record; +async function getExamplePluginPackages(): Promise { + const href = `${pathToFileURL(examplePluginsModule).href}?t=${Date.now()}`; + const { plugins } = (await import(href)) as { + plugins: { + packageNames: string[]; + registrations: Array<{ packageName?: string }>; + }; }; - return Object.keys(pkg.dependencies ?? {}).filter( - (name) => - name.startsWith("@sentry/junior-") && - name !== "@sentry/junior" && - name !== "@sentry/junior-dashboard", - ); + return [ + ...plugins.packageNames, + ...plugins.registrations.flatMap((plugin) => + plugin.packageName ? [plugin.packageName] : [], + ), + ]; } function buildJuniorPackage(): void { @@ -130,7 +133,7 @@ describe.sequential("example build discovery integration", () => { it("serves built health and recognizes the sentry oauth callback route", async () => { process.chdir(exampleRoot); process.env.JUNIOR_PLUGIN_PACKAGES = JSON.stringify( - getExamplePluginPackages(), + await getExamplePluginPackages(), ); const app = await importExampleApp(); @@ -150,7 +153,7 @@ describe.sequential("example build discovery integration", () => { }, 15_000); it("does not expose discovery state from the public example app", async () => { - const packageNames = getExamplePluginPackages(); + const packageNames = await getExamplePluginPackages(); process.chdir(exampleRoot); process.env.JUNIOR_PLUGIN_PACKAGES = JSON.stringify(packageNames); diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index 1610a12a3..cbfa84244 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp, defineJuniorPlugins } from "@/app"; import { getConfigDefaults, @@ -56,6 +56,7 @@ afterEach(async () => { setAgentPlugins([]); setPluginCatalogConfig(undefined); setConfigDefaults(undefined); + vi.doUnmock("#junior/config"); if (originalPluginPackages === undefined) { delete process.env.JUNIOR_PLUGIN_PACKAGES; } else { @@ -228,6 +229,43 @@ describe("createApp plugin config", () => { expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); }); + it("loads trusted plugin instances from the Nitro virtual plugin set", async () => { + vi.doMock("#junior/config", () => ({ + pluginSet: defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { + name: "trusted", + description: "Trusted plugin", + configKeys: ["org"], + }, + hooks: {}, + }), + ]), + plugins: { + inlineManifests: [ + { + manifest: { + name: "trusted", + description: "Trusted plugin", + capabilities: [], + configKeys: ["trusted.org"], + }, + }, + ], + }, + trustedPluginRegistrations: ["trusted"], + })); + + await createApp({ + configDefaults: { "trusted.org": "sentry" }, + }); + + expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "trusted", + ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); + }); + it("loads manifest-only package plugins by package name", async () => { const tempRoot = await makeTempDir(); await writePluginPackage(tempRoot, "@acme/full-plugin", "full"); diff --git a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts new file mode 100644 index 000000000..087b913a8 --- /dev/null +++ b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { juniorNitro } from "@/nitro"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "junior-nitro-plugin-module-"), + ); + tempDirs.push(tempDir); + return tempDir; +} + +afterEach(async () => { + for (const tempDir of tempDirs.splice(0)) { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +describe("juniorNitro plugin modules", () => { + it("injects a runtime import for plugin module references", async () => { + const tempRoot = await makeTempDir(); + await fs.writeFile( + path.join(tempRoot, "plugins.mjs"), + [ + "export const plugins = {", + ' packageNames: ["@acme/junior-demo"],', + " registrations: [],", + "};", + "", + ].join("\n"), + "utf8", + ); + + const compiledHooks: Array<() => Promise | void> = []; + const virtual: Record Promise) | string> = {}; + const nitro = { + hooks: { + hook(name: string, callback: () => Promise | void) { + if (name === "compiled") { + compiledHooks.push(callback); + } + }, + }, + options: { + output: { + serverDir: path.join(tempRoot, ".output", "server"), + }, + rootDir: tempRoot, + vercel: {}, + virtual, + }, + }; + + juniorNitro({ plugins: "./plugins" }).nitro.setup(nitro); + + const template = virtual["#junior/config"]; + expect(typeof template).toBe("function"); + const code = await (template as () => Promise)(); + + expect(code).toContain( + `import { plugins as juniorRuntimePluginSet } from ${JSON.stringify(path.join(tempRoot, "plugins.mjs").split(path.sep).join("/"))};`, + ); + expect(code).toContain( + 'export const plugins = {"packages":["@acme/junior-demo"]};', + ); + expect(compiledHooks).toHaveLength(1); + }); +}); diff --git a/packages/junior/tests/unit/build/virtual-config.test.ts b/packages/junior/tests/unit/build/virtual-config.test.ts new file mode 100644 index 000000000..f8526ee0f --- /dev/null +++ b/packages/junior/tests/unit/build/virtual-config.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { renderVirtualConfig } from "@/build/virtual-config"; + +describe("renderVirtualConfig", () => { + it("exports runtime plugin modules for createApp", () => { + const code = renderVirtualConfig({ + pluginModule: { + exportName: "plugins", + specifier: "/repo/apps/example/plugins.ts", + }, + plugins: { + packages: ["@acme/junior-demo"], + }, + trustedPluginRegistrations: ["github"], + }); + + expect(code).toContain( + 'import { plugins as juniorRuntimePluginSet } from "/repo/apps/example/plugins.ts";', + ); + expect(code).toContain("export const pluginSet = juniorRuntimePluginSet;"); + expect(code).toContain( + 'export const plugins = {"packages":["@acme/junior-demo"]};', + ); + expect(code).toContain( + 'export const trustedPluginRegistrations = ["github"];', + ); + }); + + it("supports default runtime plugin exports", () => { + const code = renderVirtualConfig({ + pluginModule: { + exportName: "default", + specifier: "@acme/junior-plugins", + }, + }); + + expect(code).toContain( + 'import juniorRuntimePluginSet from "@acme/junior-plugins";', + ); + }); +}); diff --git a/specs/dashboard.md b/specs/dashboard.md index 56a8c5f90..b28c2071d 100644 --- a/specs/dashboard.md +++ b/specs/dashboard.md @@ -251,13 +251,16 @@ The dashboard trusted plugin must: Apps should configure the dashboard explicitly: ```ts -const app = await createApp({ - plugins: [ - juniorDashboardPlugin({ - authPath: "/api/auth", - allowedGoogleDomains: ["sentry.io"], - }), - ], +export const plugins = defineJuniorPlugins([ + juniorDashboardPlugin({ + authPath: "/api/auth", + allowedGoogleDomains: ["sentry.io"], + }), +]); + +export default defineConfig({ + preset: "vercel", + modules: [juniorNitro({ plugins: "./plugins" })], }); ``` diff --git a/specs/plugin-runtime.md b/specs/plugin-runtime.md index a83015668..f832dfca6 100644 --- a/specs/plugin-runtime.md +++ b/specs/plugin-runtime.md @@ -116,11 +116,13 @@ Plugin-backed skills may explain provider commands, MCP tools, command env, conf Trusted agent behavior is initialized from app code, not `plugin.yaml`. -Apps pass one `defineJuniorPlugins(...)` set to `juniorNitro({ plugins })` and -`createApp({ plugins })`. `juniorNitro()` extracts package names for build-time -copying, while `createApp()` extracts trusted hooks and validates that every -registration has a matching manifest. Trusted factories carry their manifest -inline, so runtime code is not declared from `plugin.yaml`. +Apps export one runtime-safe `defineJuniorPlugins(...)` set and point +`juniorNitro({ plugins: "./plugins" })` at it. `juniorNitro()` extracts package +names for build-time copying and emits a virtual module that imports the same +set at runtime. `createApp()` extracts trusted hooks from that virtual module +and validates that every registration has a matching manifest. Trusted +factories carry their manifest inline, so runtime code is not declared from +`plugin.yaml`. Hook contexts expose narrow capabilities rather than raw Junior internals. Trusted plugin hook contracts are defined in [Trusted Plugin Heartbeat Spec](./trusted-plugin-heartbeat.md) and [Trusted Plugin Dispatch Spec](./trusted-plugin-dispatch.md). diff --git a/specs/plugin.md b/specs/plugin.md index 6a379e438..bc6388571 100644 --- a/specs/plugin.md +++ b/specs/plugin.md @@ -30,7 +30,7 @@ Define the plugin model for provider integrations. Plugins package declarative r 4. Skills consume plugin-provided runtime surfaces. They must not tell the agent to install CLIs, bootstrap package managers, configure credentials, repair sandbox packages, or create MCP server config. 5. Credential delivery is host-owned and requester-bound. Real provider secrets never enter sandbox env vars, files, command args, skill text, model-visible tool args, or logs. 6. Plugin-declared MCP tools are host-managed and activated only after a skill from the same plugin is loaded or the model explicitly requests that provider through the MCP bridge tools. -7. Trusted runtime behavior is app-code registration, not manifest registration. Apps pass one `defineJuniorPlugins(...)` set to both `juniorNitro({ plugins })` and `createApp({ plugins })`. +7. Trusted runtime behavior is app-code registration, not manifest registration. Apps export one runtime-safe `defineJuniorPlugins(...)` set and point `juniorNitro({ plugins: "./plugins" })` at it; `createApp()` reads the same set from Nitro's virtual module. 8. A package uses one definition source: `plugin.yaml` for declarative plugins, or a JavaScript factory with an inline manifest for trusted plugins. Do not split one plugin definition across both. 9. Core prompt text must stay plugin-agnostic. Plugin-specific behavior reaches the model through skill descriptions/bodies, tool descriptions, schemas, `promptSnippet`, `promptGuidelines`, and searched MCP descriptors. diff --git a/specs/trusted-plugin-heartbeat.md b/specs/trusted-plugin-heartbeat.md index b17994697..0a9ddf7b2 100644 --- a/specs/trusted-plugin-heartbeat.md +++ b/specs/trusted-plugin-heartbeat.md @@ -28,7 +28,10 @@ Define the trusted-plugin heartbeat and tool-registration surface needed to move ## Trust Boundary -Heartbeat and agent dispatch are trusted plugin capabilities. They are available only to Junior-owned built-in trusted plugins and plugins explicitly passed to `createApp({ plugins })` as trusted runtime plugins. +Heartbeat and agent dispatch are trusted plugin capabilities. They are +available only to Junior-owned built-in trusted plugins and plugins explicitly +enabled through the app's `defineJuniorPlugins(...)` set as trusted runtime +plugins. Declarative `plugin.yaml` manifests must not register heartbeat handlers, internal routes, or agent dispatch behavior. From e57cf2258eb8281ba5280813299a817ab12f0e88 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 09:10:06 +0200 Subject: [PATCH 3/9] fix(plugins): Reject unserializable trusted Nitro sets Fail during juniorNitro setup when a direct defineJuniorPlugins set contains trusted runtime hooks. Those hooks cannot be recovered from the virtual config unless the set is exported from an importable runtime module. Refs GH-457 Co-Authored-By: Codex GPT-5 --- .../api/interfaces/JuniorNitroOptions.md | 2 +- packages/junior/src/nitro.ts | 18 +++++++- .../unit/build/nitro-plugin-module.test.ts | 41 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md index 6738f6338..3afde4fa3 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md @@ -44,4 +44,4 @@ Defined in: [nitro.ts:35](https://github.com/getsentry/junior/blob/main/packages Defined in: [nitro.ts:37](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L37) -Plugin set or runtime-safe plugin module bundled into the app. +Plugin catalog set or runtime-safe plugin module. Direct sets must not include trusted hooks. diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index db71d4d40..46e85e1bd 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -33,7 +33,7 @@ export type JuniorNitroPluginSource = export interface JuniorNitroOptions { cwd?: string; maxDuration?: number; - /** Plugin set or runtime-safe plugin module bundled into the app. */ + /** Plugin catalog set or runtime-safe plugin module. Direct sets must not include trusted hooks. */ plugins?: JuniorNitroPluginSource; /** * Extra file patterns to copy into the server output for files that the @@ -166,6 +166,19 @@ async function loadPluginSetFromModule( ); } +function assertSerializableDirectPluginSet(pluginSet: JuniorPluginSet): void { + const trustedPluginNames = trustedPluginRegistrationsFromPluginSet( + pluginSet, + ).map((plugin) => plugin.name); + if (trustedPluginNames.length === 0) { + return; + } + + throw new Error( + `juniorNitro({ plugins }) cannot receive a direct defineJuniorPlugins(...) set with trusted plugin registration(s): ${trustedPluginNames.join(", ")}. Export the set from a runtime-safe plugin module and pass juniorNitro({ plugins: "./plugins" }) so createApp() can import the same hooks at runtime.`, + ); +} + /** Nitro module that copies app and plugin content into the Vercel build output. */ export function juniorNitro(options: JuniorNitroOptions = {}): { nitro: { setup(nitro: unknown): void }; @@ -190,6 +203,9 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { const directPluginSet = isPluginSet(pluginSource) ? pluginSource : undefined; + if (directPluginSet) { + assertSerializableDirectPluginSet(directPluginSet); + } const pluginSetPromise: Promise = pluginModule ? loadPluginSetFromModule(pluginModule) diff --git a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts index 087b913a8..a71c1c16f 100644 --- a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts +++ b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts @@ -1,8 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { afterEach, describe, expect, it } from "vitest"; import { juniorNitro } from "@/nitro"; +import { defineJuniorPlugins } from "@/plugins"; const tempDirs: string[] = []; @@ -21,6 +23,45 @@ afterEach(async () => { }); describe("juniorNitro plugin modules", () => { + it("rejects direct trusted plugin sets because hooks need a runtime import", () => { + const compiledHooks: Array<() => Promise | void> = []; + const virtual: Record Promise) | string> = {}; + const nitro = { + hooks: { + hook(name: string, callback: () => Promise | void) { + if (name === "compiled") { + compiledHooks.push(callback); + } + }, + }, + options: { + output: { + serverDir: "/tmp/junior-output", + }, + rootDir: "/tmp/junior-app", + vercel: {}, + virtual, + }, + }; + + expect(() => + juniorNitro({ + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + name: "trusted", + manifest: { + name: "trusted", + description: "Trusted plugin", + }, + hooks: {}, + }), + ]), + }).nitro.setup(nitro), + ).toThrow( + 'juniorNitro({ plugins }) cannot receive a direct defineJuniorPlugins(...) set with trusted plugin registration(s): trusted. Export the set from a runtime-safe plugin module and pass juniorNitro({ plugins: "./plugins" }) so createApp() can import the same hooks at runtime.', + ); + }); + it("injects a runtime import for plugin module references", async () => { const tempRoot = await makeTempDir(); await fs.writeFile( From d27a076e5c6dec5efe53a015d47cfa3cf16daffc Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 09:13:18 +0200 Subject: [PATCH 4/9] docs(api): Refresh generated plugin references Update generated API reference output after the plugin registration changes and docs check regeneration. Refs GH-457 Co-Authored-By: Codex GPT-5 --- .../src/content/docs/reference/api/functions/juniorNitro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md index 9aae74c38..49b821724 100644 --- a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md +++ b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md @@ -7,7 +7,7 @@ title: "juniorNitro" > **juniorNitro**(`options?`): `object` -Defined in: [nitro.ts:170](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L170) +Defined in: [nitro.ts:183](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L183) Nitro module that copies app and plugin content into the Vercel build output. From e67eb44b3854598688e2aeed28be0c14229f188a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 09:35:28 +0200 Subject: [PATCH 5/9] fix(example): Resolve dashboard plugin module import Use an explicit TypeScript extension for the dashboard config import so Node ESM can resolve the runtime plugin module during the Nitro example build. Co-Authored-By: Codex GPT-5 --- apps/example/plugins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/example/plugins.ts b/apps/example/plugins.ts index d2e85481e..a8df0b229 100644 --- a/apps/example/plugins.ts +++ b/apps/example/plugins.ts @@ -1,7 +1,7 @@ import { defineJuniorPlugins } from "@sentry/junior"; import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; import { githubPlugin } from "@sentry/junior-github"; -import { exampleDashboardAuthRequired } from "./dashboard"; +import { exampleDashboardAuthRequired } from "./dashboard.ts"; export const plugins = defineJuniorPlugins([ juniorDashboardPlugin({ From cf9b0816204bdaf52905fe8f8ae2504d40b70990 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 09:58:54 +0200 Subject: [PATCH 6/9] fix(plugins): Restore trusted plugin rebase fixtures Restore dashboard inline manifest registration and update tests to use defineJuniorPlugins after rebasing onto the latest plugin API contract. Co-Authored-By: Codex GPT-5 --- .../docs/reference/api/functions/createApp.md | 2 +- .../api/interfaces/JuniorAppOptions.md | 12 +++++------ packages/junior-dashboard/src/index.ts | 8 ++++++-- .../tests/dashboard-output.test.ts | 6 +++--- .../tests/dashboard-routes.test.ts | 6 +++--- .../junior-dashboard/tests/plugin.test.ts | 7 +++++-- .../outbound-normalization-contract.test.ts | 4 ++++ packages/junior/tests/unit/app-config.test.ts | 15 +++++++++++--- .../tests/unit/plugins/agent-hooks.test.ts | 20 +++++++++++++++++++ 9 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/docs/src/content/docs/reference/api/functions/createApp.md b/packages/docs/src/content/docs/reference/api/functions/createApp.md index 38bfb4ae5..899e3aff3 100644 --- a/packages/docs/src/content/docs/reference/api/functions/createApp.md +++ b/packages/docs/src/content/docs/reference/api/functions/createApp.md @@ -7,7 +7,7 @@ title: "createApp" > **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\> -Defined in: [app.ts:224](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L224) +Defined in: [app.ts:252](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L252) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index 96ad6d67f..d6deb6d9b 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorAppOptions" --- -Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L43) +Defined in: [app.ts:48](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L48) ## Properties @@ -13,24 +13,24 @@ Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/j > `optional` **configDefaults?**: `Record`\<`string`, `unknown`\> -Defined in: [app.ts:45](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L45) +Defined in: [app.ts:50](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L50) Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. -*** +--- ### plugins? > `optional` **plugins?**: [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) -Defined in: [app.ts:47](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L47) +Defined in: [app.ts:52](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L52) Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. -*** +--- ### waitUntil? > `optional` **waitUntil?**: `WaitUntilFn` -Defined in: [app.ts:48](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L48) +Defined in: [app.ts:53](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L53) diff --git a/packages/junior-dashboard/src/index.ts b/packages/junior-dashboard/src/index.ts index 584b43a4d..3f479650f 100644 --- a/packages/junior-dashboard/src/index.ts +++ b/packages/junior-dashboard/src/index.ts @@ -1,7 +1,7 @@ import { type AgentPluginRoute, defineJuniorPlugin, - type JuniorPlugin, + type JuniorPluginRegistration, } from "@sentry/junior-plugin-api"; import { buildDashboardConversationURL, normalizeDashboardPath } from "./url"; import { createDashboardApp, type JuniorDashboardOptions } from "./app"; @@ -47,9 +47,13 @@ function dashboardRoutes( /** Register dashboard routes and Slack footer links through trusted plugin hooks. */ export function juniorDashboardPlugin( options: JuniorDashboardPluginOptions = {}, -): JuniorPlugin { +): JuniorPluginRegistration { return defineJuniorPlugin({ name: "dashboard", + manifest: { + name: "dashboard", + description: "Junior dashboard routes and Slack footer links", + }, hooks: { routes() { if (options.disabled) { diff --git a/packages/junior-dashboard/tests/dashboard-output.test.ts b/packages/junior-dashboard/tests/dashboard-output.test.ts index 08091df75..5929f0a81 100644 --- a/packages/junior-dashboard/tests/dashboard-output.test.ts +++ b/packages/junior-dashboard/tests/dashboard-output.test.ts @@ -44,16 +44,16 @@ export default defineConfig({ ); fs.writeFileSync( path.join(root, "server.ts"), - `import { createApp } from "@sentry/junior"; + `import { createApp, defineJuniorPlugins } from "@sentry/junior"; import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; export default await createApp({ - plugins: [ + plugins: defineJuniorPlugins([ juniorDashboardPlugin({ authRequired: false, allowedGoogleDomains: ["sentry.io"], }), - ], + ]), }); `, ); diff --git a/packages/junior-dashboard/tests/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts index 33937bbc9..3942851b2 100644 --- a/packages/junior-dashboard/tests/dashboard-routes.test.ts +++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createApp } from "@sentry/junior"; +import { createApp, defineJuniorPlugins } from "@sentry/junior"; import type { JuniorReporting } from "@sentry/junior/reporting"; import { juniorDashboardPlugin } from "../src/index"; import { createDashboardApp } from "../src/app"; @@ -643,13 +643,13 @@ describe("dashboard routes", () => { it("mounts dashboard routes through the trusted plugin array", async () => { const app = await createApp({ - plugins: [ + plugins: defineJuniorPlugins([ juniorDashboardPlugin({ authRequired: false, allowedGoogleDomains: ["sentry.io"], reporting: reporting(), }), - ], + ]), }); const dashboard = await app.fetch(new Request("http://localhost/")); diff --git a/packages/junior-dashboard/tests/plugin.test.ts b/packages/junior-dashboard/tests/plugin.test.ts index 6b9b276ab..4a8f647eb 100644 --- a/packages/junior-dashboard/tests/plugin.test.ts +++ b/packages/junior-dashboard/tests/plugin.test.ts @@ -30,11 +30,14 @@ afterEach(() => { }); describe("juniorDashboardPlugin", () => { - it("does not register manifest package config", () => { + it("registers an inline dashboard manifest", () => { const plugin = juniorDashboardPlugin(); expect(plugin.name).toBe("dashboard"); - expect(plugin.pluginConfig).toBeUndefined(); + expect(plugin.manifest).toMatchObject({ + name: "dashboard", + description: "Junior dashboard routes and Slack footer links", + }); }); it("provides Slack footer links to dashboard conversation pages", () => { diff --git a/packages/junior/tests/integration/slack/outbound-normalization-contract.test.ts b/packages/junior/tests/integration/slack/outbound-normalization-contract.test.ts index 715ede9ce..dc76642b7 100644 --- a/packages/junior/tests/integration/slack/outbound-normalization-contract.test.ts +++ b/packages/junior/tests/integration/slack/outbound-normalization-contract.test.ts @@ -89,6 +89,10 @@ describe("Slack contract: outbound normalization", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "dashboard", + manifest: { + name: "dashboard", + description: "Dashboard", + }, hooks: { slackConversationLink(ctx) { return { diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index cbfa84244..794f77506 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -97,7 +97,7 @@ describe("createApp plugin config", () => { expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); }); - it("merges env plugin packages with trusted runtime plugins", async () => { + it("loads package plugins with trusted runtime plugins", async () => { const tempRoot = await makeTempDir(); await writePluginPackage(tempRoot, "@acme/env-plugin", "env"); await fs.writeFile( @@ -112,14 +112,23 @@ describe("createApp plugin config", () => { "utf8", ); process.chdir(tempRoot); - process.env.JUNIOR_PLUGIN_PACKAGES = JSON.stringify(["@acme/env-plugin"]); await createApp({ - plugins: [defineJuniorPlugin({ name: "dashboard" })], + plugins: defineJuniorPlugins([ + "@acme/env-plugin", + defineJuniorPlugin({ + manifest: { + name: "dashboard", + description: "Dashboard plugin", + }, + hooks: {}, + }), + ]), configDefaults: { "env.org": "sentry" }, }); expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "dashboard", "env", ]); expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 323f6acc5..86e9370c3 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -181,6 +181,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -212,6 +216,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -238,6 +246,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -264,6 +276,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -295,6 +311,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { slackConversationLink() { return { url: "javascript:alert(1)" }; From bda9ac90fb0d32ebc6edf3ee5482b4abb6eb2e48 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 10:50:47 +0200 Subject: [PATCH 7/9] fix(plugins): Validate trusted inline manifests Parse trusted JavaScript plugin manifests through the same effective manifest pipeline as plugin.yaml so install-level overrides and validation apply consistently. Update dashboard and plugin registration docs to point at defineJuniorPlugins module registration after the plugin API changes. Refs GH-457 Co-Authored-By: Codex GPT-5 --- .../src/content/docs/extend/vercel-plugin.md | 16 +- .../src/content/docs/operate/dashboard.md | 36 +++-- packages/junior-vercel/README.md | 16 +- .../chat/plugins/inline-manifest-source.ts | 146 ++++++++++++++++++ packages/junior/src/chat/plugins/manifest.ts | 88 ++++++----- packages/junior/src/chat/plugins/registry.ts | 10 +- packages/junior/tests/unit/app-config.test.ts | 63 ++++++++ .../plugins/plugin-inline-manifest.test.ts | 71 +++++++++ specs/dashboard.md | 8 +- 9 files changed, 380 insertions(+), 74 deletions(-) create mode 100644 packages/junior/src/chat/plugins/inline-manifest-source.ts create mode 100644 packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts diff --git a/packages/docs/src/content/docs/extend/vercel-plugin.md b/packages/docs/src/content/docs/extend/vercel-plugin.md index 2f81a5ebb..84c4d29ea 100644 --- a/packages/docs/src/content/docs/extend/vercel-plugin.md +++ b/packages/docs/src/content/docs/extend/vercel-plugin.md @@ -25,14 +25,18 @@ pnpm add @sentry/junior @sentry/junior-vercel ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: +Add the plugin package to the plugin set exported from `plugins.ts`: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-vercel"]); +``` + +Point `juniorNitro()` at that plugin module: ```ts title="nitro.config.ts" -juniorNitro({ - plugins: { - packages: ["@sentry/junior-vercel"], - }, -}); +juniorNitro({ plugins: "./plugins" }); ``` Set a Vercel token in your Junior deployment environment: diff --git a/packages/docs/src/content/docs/operate/dashboard.md b/packages/docs/src/content/docs/operate/dashboard.md index 02c0aa293..93fb9579e 100644 --- a/packages/docs/src/content/docs/operate/dashboard.md +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -24,26 +24,30 @@ pnpm add @sentry/junior-dashboard ## Register the plugin -Register `juniorDashboardPlugin()` in the trusted plugin array passed to -`createApp()`. Configure the Google Workspace domain that should be allowed to -view the dashboard: +Register `juniorDashboardPlugin()` in the runtime-safe plugin set. Configure the +Google Workspace domain that should be allowed to view the dashboard: + +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; +import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; + +export const plugins = defineJuniorPlugins([ + juniorDashboardPlugin({ + allowedGoogleDomains: ["sentry.io"], + trustedOrigins: ["https://"], + }), +]); +``` + +`createApp()` reads that plugin set from Nitro's virtual config: ```ts title="server.ts" import { createApp } from "@sentry/junior"; -import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; -export default await createApp({ - plugins: [ - juniorDashboardPlugin({ - allowedGoogleDomains: ["sentry.io"], - trustedOrigins: ["https://"], - }), - ], -}); +export default await createApp(); ``` -Keep the normal Junior Nitro module in `nitro.config.ts`; the dashboard routes -are mounted by the trusted plugin at runtime: +Point the Junior Nitro module at the same plugin module: ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; @@ -53,9 +57,7 @@ export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: ["@sentry/junior-sentry"], - }, + plugins: "./plugins", }), ], routes: { diff --git a/packages/junior-vercel/README.md b/packages/junior-vercel/README.md index d54b01c48..2f0ab42aa 100644 --- a/packages/junior-vercel/README.md +++ b/packages/junior-vercel/README.md @@ -10,14 +10,18 @@ pnpm add @sentry/junior @sentry/junior-vercel ## Configure -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: +Add the package name to the plugin set exported from `plugins.ts`: ```ts -juniorNitro({ - plugins: { - packages: ["@sentry/junior-vercel"], - }, -}); +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-vercel"]); +``` + +Point `juniorNitro()` at that plugin module: + +```ts +juniorNitro({ plugins: "./plugins" }); ``` Set a Vercel token in the Junior deployment environment: diff --git a/packages/junior/src/chat/plugins/inline-manifest-source.ts b/packages/junior/src/chat/plugins/inline-manifest-source.ts new file mode 100644 index 000000000..ffdccb5b5 --- /dev/null +++ b/packages/junior/src/chat/plugins/inline-manifest-source.ts @@ -0,0 +1,146 @@ +import type { PluginManifest } from "./types"; + +type ManifestSource = Record; + +function setDefined( + target: Record, + key: string, + value: unknown, +): void { + if (value !== undefined) { + target[key] = value; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function unqualifyManifestToken(name: unknown, value: unknown): unknown { + if ( + typeof name === "string" && + typeof value === "string" && + value.startsWith(`${name}.`) + ) { + return value.slice(name.length + 1); + } + return value; +} + +function inlineTokenListSource(name: unknown, values: unknown): unknown { + if (values === undefined || !Array.isArray(values)) { + return values; + } + return values.map((value) => unqualifyManifestToken(name, value)); +} + +function inlineCredentialsSource( + credentials: PluginManifest["credentials"], +): unknown { + if (credentials === undefined || !isRecord(credentials)) { + return credentials; + } + + const result: ManifestSource = {}; + setDefined(result, "type", credentials.type); + setDefined(result, "domains", credentials.domains); + setDefined(result, "api-headers", credentials.apiHeaders); + setDefined(result, "auth-token-env", credentials.authTokenEnv); + setDefined( + result, + "auth-token-placeholder", + credentials.authTokenPlaceholder, + ); + if (credentials.type === "github-app") { + setDefined(result, "app-id-env", credentials.appIdEnv); + setDefined(result, "private-key-env", credentials.privateKeyEnv); + setDefined(result, "installation-id-env", credentials.installationIdEnv); + } + return result; +} + +function inlineMcpSource(mcp: PluginManifest["mcp"]): unknown { + if (mcp === undefined || !isRecord(mcp)) { + return mcp; + } + + const result: ManifestSource = {}; + setDefined(result, "transport", mcp.transport); + setDefined(result, "url", mcp.url); + setDefined(result, "headers", mcp.headers); + setDefined(result, "allowed-tools", mcp.allowedTools); + return result; +} + +function inlineOauthSource(oauth: PluginManifest["oauth"]): unknown { + if (oauth === undefined || !isRecord(oauth)) { + return oauth; + } + + const result: ManifestSource = {}; + setDefined(result, "client-id-env", oauth.clientIdEnv); + setDefined(result, "client-secret-env", oauth.clientSecretEnv); + setDefined(result, "authorize-endpoint", oauth.authorizeEndpoint); + setDefined(result, "token-endpoint", oauth.tokenEndpoint); + setDefined(result, "scope", oauth.scope); + setDefined(result, "authorize-params", oauth.authorizeParams); + setDefined(result, "token-auth-method", oauth.tokenAuthMethod); + setDefined(result, "token-extra-headers", oauth.tokenExtraHeaders); + return result; +} + +function inlineTargetSource( + name: unknown, + target: PluginManifest["target"], +): unknown { + if (target === undefined || !isRecord(target)) { + return target; + } + + const result: ManifestSource = {}; + setDefined(result, "type", target.type); + setDefined( + result, + "config-key", + unqualifyManifestToken(name, target.configKey), + ); + setDefined(result, "command-flags", target.commandFlags); + return result; +} + +/** Convert inline JavaScript plugin manifests to the canonical source shape. */ +export function inlineManifestSource(manifest: PluginManifest): ManifestSource { + const result: ManifestSource = {}; + + setDefined(result, "name", manifest.name); + setDefined(result, "description", manifest.description); + setDefined( + result, + "capabilities", + inlineTokenListSource(manifest.name, manifest.capabilities), + ); + setDefined( + result, + "config-keys", + inlineTokenListSource(manifest.name, manifest.configKeys), + ); + setDefined(result, "domains", manifest.domains); + setDefined(result, "api-headers", manifest.apiHeaders); + setDefined(result, "command-env", manifest.commandEnv); + setDefined(result, "env-vars", manifest.envVars); + setDefined( + result, + "credentials", + inlineCredentialsSource(manifest.credentials), + ); + setDefined(result, "runtime-dependencies", manifest.runtimeDependencies); + setDefined(result, "runtime-postinstall", manifest.runtimePostinstall); + setDefined(result, "mcp", inlineMcpSource(manifest.mcp)); + setDefined(result, "oauth", inlineOauthSource(manifest.oauth)); + setDefined( + result, + "target", + inlineTargetSource(manifest.name, manifest.target), + ); + return result; +} diff --git a/packages/junior/src/chat/plugins/manifest.ts b/packages/junior/src/chat/plugins/manifest.ts index 1414284ac..71843e7fe 100644 --- a/packages/junior/src/chat/plugins/manifest.ts +++ b/packages/junior/src/chat/plugins/manifest.ts @@ -16,6 +16,7 @@ import type { PluginSystemRuntimeDependency, PluginSystemRuntimeDependencyFromUrl, } from "./types"; +import { inlineManifestSource } from "./inline-manifest-source"; const PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; const SHORT_CAPABILITY_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/; @@ -966,100 +967,80 @@ function normalizeMcp( } satisfies PluginMcpConfig; } -/** Parse one plugin manifest after applying install-level plugin config. */ -export function parsePluginManifest( - raw: string, +function parseManifestSource( + parsedSource: ManifestSource, dir: string, config?: PluginCatalogConfig, ): PluginManifest { - let parsedYaml: unknown; - try { - parsedYaml = parseYaml(raw); - } catch (error) { - throw new Error( - `Invalid plugin manifest in ${dir}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - if ( - !parsedYaml || - typeof parsedYaml !== "object" || - Array.isArray(parsedYaml) - ) { - throw new Error(`Invalid plugin manifest in ${dir}: expected an object`); - } - - const source = applyManifestConfig(parsedYaml as ManifestSource, config); + const source = applyManifestConfig(parsedSource, config); const sourceResult = manifestSourceSchema.safeParse(source); if (!sourceResult.success) { const issue = sourceResult.error.issues[0]; const path = formatPath(issue?.path ?? []); if (path === "name") { - throw new Error( - `Invalid plugin name in ${dir}: "${(parsedYaml as { name?: unknown }).name}"`, - ); + throw new Error(`Invalid plugin name in ${dir}: "${parsedSource.name}"`); } if (path === "description") { throw new Error(`Invalid plugin description in ${dir}`); } if (path === "capabilities") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} capabilities must be an array when provided`, + `Plugin ${String(parsedSource.name ?? "unknown")} capabilities must be an array when provided`, ); } if (path === "config-keys") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} config-keys must be an array when provided`, + `Plugin ${String(parsedSource.name ?? "unknown")} config-keys must be an array when provided`, ); } if (path === "domains") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} ${path} must be a non-empty array of domains`, + `Plugin ${String(parsedSource.name ?? "unknown")} ${path} must be a non-empty array of domains`, ); } if (path === "api-headers") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} api-headers must be an object when provided`, + `Plugin ${String(parsedSource.name ?? "unknown")} api-headers must be an object when provided`, ); } if (path === "command-env") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} command-env must be an object when provided`, + `Plugin ${String(parsedSource.name ?? "unknown")} command-env must be an object when provided`, ); } if (path === "credentials") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} credentials must be an object when provided`, + `Plugin ${String(parsedSource.name ?? "unknown")} credentials must be an object when provided`, ); } if (path === "runtime-dependencies") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} runtime-dependencies must be an array`, + `Plugin ${String(parsedSource.name ?? "unknown")} runtime-dependencies must be an array`, ); } if (path === "runtime-postinstall") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} runtime-postinstall must be an array`, + `Plugin ${String(parsedSource.name ?? "unknown")} runtime-postinstall must be an array`, ); } if (path === "env-vars") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} env-vars must be an object`, + `Plugin ${String(parsedSource.name ?? "unknown")} env-vars must be an object`, ); } if (path === "mcp") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} mcp must be an object`, + `Plugin ${String(parsedSource.name ?? "unknown")} mcp must be an object`, ); } if (path === "oauth") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} oauth must be an object`, + `Plugin ${String(parsedSource.name ?? "unknown")} oauth must be an object`, ); } if (path === "target") { throw new Error( - `Plugin ${(parsedYaml as { name?: string }).name ?? "unknown"} target must be an object`, + `Plugin ${String(parsedSource.name ?? "unknown")} target must be an object`, ); } throw new Error(issue?.message ?? `Invalid plugin manifest in ${dir}`); @@ -1230,3 +1211,38 @@ export function parsePluginManifest( return manifest; } + +/** Parse one plugin.yaml manifest after applying install-level plugin config. */ +export function parsePluginManifest( + raw: string, + dir: string, + config?: PluginCatalogConfig, +): PluginManifest { + let parsedYaml: unknown; + try { + parsedYaml = parseYaml(raw); + } catch (error) { + throw new Error( + `Invalid plugin manifest in ${dir}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if ( + !parsedYaml || + typeof parsedYaml !== "object" || + Array.isArray(parsedYaml) + ) { + throw new Error(`Invalid plugin manifest in ${dir}: expected an object`); + } + + return parseManifestSource(parsedYaml as ManifestSource, dir, config); +} + +/** Parse one inline JavaScript manifest through the same effective manifest pipeline as plugin.yaml. */ +export function parseInlinePluginManifest( + manifest: PluginManifest, + dir: string, + config?: PluginCatalogConfig, +): PluginManifest { + return parseManifestSource(inlineManifestSource(manifest), dir, config); +} diff --git a/packages/junior/src/chat/plugins/registry.ts b/packages/junior/src/chat/plugins/registry.ts index d9cdcf7b8..07ab22e76 100644 --- a/packages/junior/src/chat/plugins/registry.ts +++ b/packages/junior/src/chat/plugins/registry.ts @@ -5,7 +5,7 @@ import type { CredentialBroker } from "@/chat/credentials/broker"; import { pluginRoots } from "@/chat/discovery"; import { logInfo, logWarn, setSpanAttributes } from "@/chat/logging"; import { createGitHubAppBroker } from "./auth/github-app-broker"; -import { parsePluginManifest } from "./manifest"; +import { parseInlinePluginManifest, parsePluginManifest } from "./manifest"; import { createOAuthBearerBroker } from "./auth/oauth-bearer-broker"; import { createApiHeadersBroker } from "./auth/api-headers-broker"; import { @@ -225,12 +225,12 @@ function registerInlineManifests( const skillsDir = pkg?.hasSkillsDir ? path.join(pkg.dir, "skills") : path.join(dir, "skills"); - registerPluginManifest( - state, - structuredClone(definition.manifest), + const manifest = parseInlinePluginManifest( + definition.manifest, dir, - skillsDir, + pluginConfig, ); + registerPluginManifest(state, manifest, dir, skillsDir); } } diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index 794f77506..553f36334 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -238,6 +238,69 @@ describe("createApp plugin config", () => { expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); }); + it("applies manifest overrides to trusted plugin inline manifests", async () => { + await createApp({ + plugins: defineJuniorPlugins( + [ + defineJuniorPlugin({ + manifest: { + name: "trusted", + description: "Trusted plugin", + credentials: { + type: "oauth-bearer", + domains: ["old.example.com"], + authTokenEnv: "TRUSTED_TOKEN", + }, + }, + hooks: {}, + }), + ], + { + manifests: { + trusted: { + credentials: { + domains: ["new.example.com"], + }, + }, + }, + }, + ), + }); + + expect( + getPluginProviders().map((plugin) => ({ + name: plugin.manifest.name, + domains: plugin.manifest.credentials?.domains, + })), + ).toEqual([{ name: "trusted", domains: ["new.example.com"] }]); + }); + + it("rejects invalid trusted plugin inline manifests before mutating app config", async () => { + await createApp({ + plugins: defineJuniorPlugins([]), + }); + + await expect( + createApp({ + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { + name: "invalid", + description: "Invalid plugin", + domains: ["api.example.com"], + }, + hooks: {}, + }), + ]), + }), + ).rejects.toThrow( + "Plugin invalid domains requires credentials or api-headers", + ); + + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); + expect(getPluginProviders()).toEqual([]); + }); + it("loads trusted plugin instances from the Nitro virtual plugin set", async () => { vi.doMock("#junior/config", () => ({ pluginSet: defineJuniorPlugins([ diff --git a/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts b/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts new file mode 100644 index 000000000..2e61e31fb --- /dev/null +++ b/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { parseInlinePluginManifest } from "@/chat/plugins/manifest"; +import type { PluginManifest } from "@/chat/plugins/types"; + +function parse(manifest: unknown): PluginManifest { + return parseInlinePluginManifest( + manifest as PluginManifest, + "/plugins/inline", + ); +} + +describe("inline plugin manifests", () => { + it("rejects invalid values instead of dropping them before validation", () => { + const cases: Array<[string, Record, string]> = [ + [ + "capabilities", + { capabilities: null }, + "Plugin bad-capabilities capabilities must be an array when provided", + ], + [ + "config-keys", + { configKeys: null }, + "Plugin bad-config-keys config-keys must be an array when provided", + ], + [ + "credentials", + { credentials: null }, + "Plugin bad-credentials credentials must be an object when provided", + ], + ["mcp", { mcp: null }, "Plugin bad-mcp mcp must be an object"], + ["oauth", { oauth: null }, "Plugin bad-oauth oauth must be an object"], + [ + "target", + { target: null }, + "Plugin bad-target target must be an object", + ], + ]; + + for (const [name, patch, message] of cases) { + expect(() => + parse({ + name: `bad-${name}`, + description: "Bad inline manifest", + ...patch, + }), + ).toThrow(message); + } + }); + + it("lets the manifest parser report malformed inline tokens", () => { + expect(() => + parse({ + name: "bad-capability-token", + description: "Bad inline manifest", + capabilities: [123], + }), + ).toThrow("Invalid input: expected string"); + + expect(() => + parse({ + name: "bad-target-token", + description: "Bad inline manifest", + configKeys: ["repo"], + target: { + type: "repo", + configKey: 123, + }, + }), + ).toThrow("Plugin bad-target-token target.config-key Invalid input"); + }); +}); diff --git a/specs/dashboard.md b/specs/dashboard.md index b28c2071d..db6273df9 100644 --- a/specs/dashboard.md +++ b/specs/dashboard.md @@ -99,13 +99,13 @@ export interface JuniorDashboardPluginOptions { export function juniorDashboardPlugin( options?: JuniorDashboardPluginOptions, -): JuniorPlugin; +): JuniorPluginRegistration; ``` The trusted plugin is the normal dashboard integration path. When registered -with `createApp({ plugins: [juniorDashboardPlugin(...)] })`, it mounts the -dashboard/auth HTTP routes and supplies dashboard conversation URLs for -finalized Slack reply footers. It must not expose dashboard data or tools to +through a `defineJuniorPlugins([juniorDashboardPlugin(...)])` plugin set, it +mounts the dashboard/auth HTTP routes and supplies dashboard conversation URLs +for finalized Slack reply footers. It must not expose dashboard data or tools to agent turns. `authRequired` defaults to `true`. Setting `authRequired: false` is only for explicit local/demo deployments and must bypass dashboard auth only for dashboard routes. Production configuration must not silently disable dashboard auth. From 0d84269c5cbab0d9cbe37f4af0bb935e62eea8ae Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 11:08:52 +0200 Subject: [PATCH 8/9] ref(plugins): Defer Nitro plugin module loading Load runtime plugin modules only when the virtual config or compiled hook needs the plugin set. This keeps Nitro setup from starting asynchronous module imports early while preserving the shared plugin-set result. Also remove a stale plugins.packages label from the generic package-name validator. Refs GH-457 Co-Authored-By: Codex GPT-5 --- .../src/chat/plugins/package-discovery.ts | 2 +- packages/junior/src/nitro.ts | 11 +++-- .../unit/build/nitro-plugin-module.test.ts | 47 +++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/junior/src/chat/plugins/package-discovery.ts b/packages/junior/src/chat/plugins/package-discovery.ts index 13ce5872e..54c903b0d 100644 --- a/packages/junior/src/chat/plugins/package-discovery.ts +++ b/packages/junior/src/chat/plugins/package-discovery.ts @@ -65,7 +65,7 @@ export function normalizePluginPackageNames(packageNames: unknown): string[] { } if (!Array.isArray(packageNames)) { - throw new Error("plugins.packages must be an array of package names"); + throw new Error("Plugin package names must be an array"); } const normalized: string[] = []; diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index 46e85e1bd..a04757bf9 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -206,10 +206,13 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { if (directPluginSet) { assertSerializableDirectPluginSet(directPluginSet); } - const pluginSetPromise: Promise = - pluginModule + let pluginSetPromise: Promise | undefined; + const loadConfiguredPluginSet = () => { + pluginSetPromise ??= pluginModule ? loadPluginSetFromModule(pluginModule) : Promise.resolve(directPluginSet); + return pluginSetPromise; + }; const pluginCatalogConfig = pluginCatalogConfigFromPluginSet(directPluginSet); const trustedPluginRegistrations = @@ -219,7 +222,7 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { injectVirtualConfig(nitro, { ...(pluginModule ? { - loadPluginSet: () => pluginSetPromise, + loadPluginSet: loadConfiguredPluginSet, pluginModule: pluginModule.runtimeModule, } : {}), @@ -228,7 +231,7 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { }); nitro.hooks.hook("compiled", async () => { - const pluginSet = await pluginSetPromise; + const pluginSet = await loadConfiguredPluginSet(); const compiledPluginCatalogConfig = pluginCatalogConfigFromPluginSet(pluginSet); copyAppAndPluginContent( diff --git a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts index a71c1c16f..42f7a06e1 100644 --- a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts +++ b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts @@ -23,6 +23,53 @@ afterEach(async () => { }); describe("juniorNitro plugin modules", () => { + it("loads plugin modules lazily when virtual config is rendered", async () => { + const tempRoot = await makeTempDir(); + await fs.writeFile( + path.join(tempRoot, "plugins.mjs"), + [ + "globalThis.__juniorNitroPluginModuleImports = (globalThis.__juniorNitroPluginModuleImports ?? 0) + 1;", + "export const plugins = {", + " packageNames: [],", + " registrations: [],", + "};", + "", + ].join("\n"), + "utf8", + ); + const globalState = globalThis as typeof globalThis & { + __juniorNitroPluginModuleImports?: number; + }; + delete globalState.__juniorNitroPluginModuleImports; + + const virtual: Record Promise) | string> = {}; + const nitro = { + hooks: { + hook() {}, + }, + options: { + output: { + serverDir: path.join(tempRoot, ".output", "server"), + }, + rootDir: tempRoot, + vercel: {}, + virtual, + }, + }; + + juniorNitro({ plugins: "./plugins" }).nitro.setup(nitro); + await new Promise((resolve) => setTimeout(resolve, 25)); + + expect(globalState.__juniorNitroPluginModuleImports).toBeUndefined(); + + const template = virtual["#junior/config"]; + expect(typeof template).toBe("function"); + await (template as () => Promise)(); + + expect(globalState.__juniorNitroPluginModuleImports).toBe(1); + delete globalState.__juniorNitroPluginModuleImports; + }); + it("rejects direct trusted plugin sets because hooks need a runtime import", () => { const compiledHooks: Array<() => Promise | void> = []; const virtual: Record Promise) | string> = {}; From d65753721574abf916bd3b224f9a087fe3ab888e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 11:27:43 +0200 Subject: [PATCH 9/9] fix(plugins): Preserve inline plugin skill ownership Inline trusted plugin registrations should only own package skills when their declared package actually ships a skills directory. Do not assign app-level skills to package-less trusted plugins. Refs GH-457 Co-Authored-By: Codex GPT-5 --- packages/junior/src/chat/plugins/registry.ts | 24 ++++++-- packages/junior/src/chat/plugins/types.ts | 2 +- packages/junior/tests/unit/app-config.test.ts | 61 +++++++++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/junior/src/chat/plugins/registry.ts b/packages/junior/src/chat/plugins/registry.ts index 07ab22e76..6ea54a59a 100644 --- a/packages/junior/src/chat/plugins/registry.ts +++ b/packages/junior/src/chat/plugins/registry.ts @@ -77,7 +77,7 @@ function registerPluginManifest( state: LoadedPluginState, manifest: PluginDefinition["manifest"], pluginDir: string, - skillsDir = path.join(pluginDir, "skills"), + skillsDir?: string, ): void { if (state.pluginsByName.has(manifest.name)) { throw new Error(`Duplicate plugin name "${manifest.name}"`); @@ -103,7 +103,7 @@ function registerPluginManifest( const definition: PluginDefinition = { manifest, dir: pluginDir, - skillsDir, + ...(skillsDir ? { skillsDir } : {}), }; state.pluginDefinitions.push(definition); @@ -126,7 +126,12 @@ function registerYamlPluginManifest( pluginDir: string, ): void { const manifest = parsePluginManifest(raw, pluginDir, pluginConfig); - registerPluginManifest(state, manifest, pluginDir); + registerPluginManifest( + state, + manifest, + pluginDir, + path.join(pluginDir, "skills"), + ); } function normalizePluginRoots(roots: string[]): string[] { @@ -224,7 +229,7 @@ function registerInlineManifests( const dir = pkg?.dir ?? process.cwd(); const skillsDir = pkg?.hasSkillsDir ? path.join(pkg.dir, "skills") - : path.join(dir, "skills"); + : undefined; const manifest = parseInlinePluginManifest( definition.manifest, dir, @@ -350,7 +355,9 @@ function logLoadedPlugins(state: LoadedPluginState): void { "app.plugin.config_key_count": plugin.manifest.configKeys.length, "app.plugin.has_mcp": Boolean(plugin.manifest.mcp), "file.directory": plugin.dir, - "app.file.skill_directory": plugin.skillsDir, + ...(plugin.skillsDir + ? { "app.file.skill_directory": plugin.skillsDir } + : {}), }, "Loaded plugin", ); @@ -506,7 +513,9 @@ export function getPluginSkillRoots(): string[] { const state = ensurePluginsLoaded(); return [ ...new Set([ - ...state.pluginDefinitions.map((plugin) => plugin.skillsDir), + ...state.pluginDefinitions.flatMap((plugin) => + plugin.skillsDir ? [plugin.skillsDir] : [], + ), ...state.packageSkillRoots, ]), ]; @@ -519,6 +528,9 @@ export function getPluginForSkillPath( const resolvedSkillPath = path.resolve(skillPath); return state.pluginDefinitions.find((plugin) => { + if (!plugin.skillsDir) { + return false; + } const resolvedSkillsDir = path.resolve(plugin.skillsDir); return ( resolvedSkillPath === resolvedSkillsDir || diff --git a/packages/junior/src/chat/plugins/types.ts b/packages/junior/src/chat/plugins/types.ts index 2ab0257be..0c01878a0 100644 --- a/packages/junior/src/chat/plugins/types.ts +++ b/packages/junior/src/chat/plugins/types.ts @@ -171,7 +171,7 @@ export interface PluginBrokerDeps { export interface PluginDefinition { manifest: PluginManifest; dir: string; - skillsDir: string; + skillsDir?: string; } export interface InlinePluginManifestDefinition { diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index 553f36334..8b30ade53 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -10,6 +10,7 @@ import { } from "@/chat/configuration/defaults"; import { getAgentPlugins, setAgentPlugins } from "@/chat/plugins/agent-hooks"; import { + getPluginSkillRoots, getPluginProviders, setPluginCatalogConfig, } from "@/chat/plugins/registry"; @@ -238,6 +239,66 @@ describe("createApp plugin config", () => { expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); }); + it("does not assign app skills to trusted inline plugins", async () => { + const tempRoot = await makeTempDir(); + await fs.mkdir(path.join(tempRoot, "skills", "notes"), { + recursive: true, + }); + process.chdir(tempRoot); + + await createApp({ + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { + name: "trusted", + description: "Trusted plugin", + }, + hooks: {}, + }), + ]), + }); + + expect(getPluginSkillRoots()).toEqual([]); + }); + + it("assigns package skills to trusted inline plugin packages", async () => { + const tempRoot = await makeTempDir(); + const packageRoot = path.join( + tempRoot, + "node_modules", + "@acme", + "trusted-plugin", + ); + await fs.mkdir(path.join(packageRoot, "skills", "triage"), { + recursive: true, + }); + process.chdir(tempRoot); + + await createApp({ + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + packageName: "@acme/trusted-plugin", + manifest: { + name: "trusted", + description: "Trusted plugin", + }, + hooks: {}, + }), + ]), + }); + + const resolvedTempRoot = await fs.realpath(tempRoot); + expect(getPluginSkillRoots()).toEqual([ + path.join( + resolvedTempRoot, + "node_modules", + "@acme", + "trusted-plugin", + "skills", + ), + ]); + }); + it("applies manifest overrides to trusted plugin inline manifests", async () => { await createApp({ plugins: defineJuniorPlugins(