diff --git a/apps/example/README.md b/apps/example/README.md index b0fbe769d..ca07a6f4a 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` 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 4352440bb..33d60f711 100644 --- a/apps/example/nitro.config.ts +++ b/apps/example/nitro.config.ts @@ -1,14 +1,11 @@ import { defineConfig } from "nitro"; import { juniorNitro } from "@sentry/junior/nitro"; -import { examplePluginPackages } from "./plugin-packages"; export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: examplePluginPackages, - }, + plugins: "./plugins", }), ], 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..a8df0b229 --- /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.ts"; + +export const plugins = 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..68312fc90 100644 --- a/apps/example/server.ts +++ b/apps/example/server.ts @@ -1,17 +1,9 @@ import { createApp } from "@sentry/junior"; -import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; import { initSentry } from "@sentry/junior/instrumentation"; -import { exampleDashboardAuthRequired } from "./dashboard"; initSentry(); const app = await createApp({ - plugins: [ - juniorDashboardPlugin({ - authRequired: exampleDashboardAuthRequired(), - allowedGoogleDomains: ["sentry.io"], - }), - ], 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..59730c06e 100644 --- a/packages/docs/src/content/docs/extend/_plugin-template.md +++ b/packages/docs/src/content/docs/extend/_plugin-template.md @@ -21,14 +21,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..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,14 +22,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..f04e7938c 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,27 @@ 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 +runtime-safe 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. +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 -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 +132,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 +155,20 @@ export function myProviderPlugin() { } ``` -Register the trusted plugin from the app: +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. -```ts title="server.ts" -import { createApp } from "@sentry/junior"; -import { myProviderPlugin } from "@acme/junior-my-provider"; +Enable the trusted plugin from the app plugin module: -const app = await createApp({ - plugins: [myProviderPlugin()], -}); +```ts title="plugins.ts" +import { defineJuniorPlugins } from "@sentry/junior"; +import { myProviderPlugin } from "@acme/junior-my-provider"; -export default app; +export const plugins = defineJuniorPlugins([myProviderPlugin()]); ``` -`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 +192,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 +224,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..0835a1814 100644 --- a/packages/docs/src/content/docs/extend/datadog-plugin.md +++ b/packages/docs/src/content/docs/extend/datadog-plugin.md @@ -25,14 +25,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..5051d298a 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -21,35 +21,19 @@ 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 plugin set exported from `plugins.ts`. 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..78d81e7cf 100644 --- a/packages/docs/src/content/docs/extend/hex-plugin.md +++ b/packages/docs/src/content/docs/extend/hex-plugin.md @@ -25,14 +25,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..2117c8bfd 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,7 +59,31 @@ 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 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"; +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"; @@ -68,19 +93,7 @@ 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: "./plugins", }), ], routes: { @@ -89,59 +102,52 @@ 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"; -```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(); + +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 +160,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 +325,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 +343,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 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 5f499d19a..4e70977da 100644 --- a/packages/docs/src/content/docs/extend/linear-plugin.md +++ b/packages/docs/src/content/docs/extend/linear-plugin.md @@ -23,14 +23,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..6c83bfbe6 100644 --- a/packages/docs/src/content/docs/extend/notion-plugin.md +++ b/packages/docs/src/content/docs/extend/notion-plugin.md @@ -25,14 +25,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..38cdb4f3b 100644 --- a/packages/docs/src/content/docs/extend/scheduler-plugin.md +++ b/packages/docs/src/content/docs/extend/scheduler-plugin.md @@ -22,27 +22,14 @@ 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 plugin set exported from `plugins.ts`. 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..4edfdb2c2 100644 --- a/packages/docs/src/content/docs/extend/sentry-plugin.md +++ b/packages/docs/src/content/docs/extend/sentry-plugin.md @@ -23,14 +23,12 @@ 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 plugin set exported from `plugins.ts`: + +```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/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/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..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:216](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L216) +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/functions/defineJuniorPlugins.md b/packages/docs/src/content/docs/reference/api/functions/defineJuniorPlugins.md new file mode 100644 index 000000000..98362a54f --- /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](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L102) + +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..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:26](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L26) +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. 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..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:35](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L35) +Defined in: [app.ts:48](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L48) ## Properties @@ -13,28 +13,24 @@ 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: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?**: `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:52](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L52) -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()`. +Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. -*** +--- ### waitUntil? > `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:53](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L53) 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..3afde4fa3 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:33](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L33) ## 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:34](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L34) *** @@ -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: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:13](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L13) +Defined in: [nitro.ts:35](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L35) *** ### plugins? -> `optional` **plugins?**: `PluginConfig` +> `optional` **plugins?**: `JuniorNitroPluginSource` -Defined in: [nitro.ts:15](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L15) +Defined in: [nitro.ts:37](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L37) -Plugin packages and manifest overrides bundled into the app. +Plugin catalog set or runtime-safe plugin module. Direct sets must not include trusted hooks. 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..b16c5bc33 --- /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](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L16) + +Reusable plugin registrations and manifest overrides. + +## Properties + +### manifests? + +> `optional` **manifests?**: `Record`\<`string`, `PluginManifestConfig`\> + +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. + +--- + +### packageNames + +> **packageNames**: `string`[] + +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. + +--- + +### registrations + +> **registrations**: `JuniorPluginRegistration`[] + +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 new file mode 100644 index 000000000..61a252d28 --- /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](https://github.com/getsentry/junior/blob/main/packages/junior/src/plugins.ts#L10) + +## Properties + +### manifests? + +> `optional` **manifests?**: `Record`\<`string`, `PluginManifestConfig`\> + +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 new file mode 100644 index 000000000..ae5ab70b1 --- /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](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 af1e54529..a9ee46842 100644 --- a/packages/docs/src/content/docs/start-here/existing-app.md +++ b/packages/docs/src/content/docs/start-here/existing-app.md @@ -34,7 +34,14 @@ export default app; ## Add Nitro wiring -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"; + +export const plugins = defineJuniorPlugins(["@sentry/junior-sentry"]); +``` ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; @@ -43,9 +50,7 @@ import { juniorNitro } from "@sentry/junior/nitro"; export default defineConfig({ modules: [ juniorNitro({ - plugins: { - packages: ["@sentry/junior-sentry"], - }, + plugins: "./plugins", }), ], routes: { @@ -56,10 +61,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..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,30 @@ 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 runtime-safe 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", +]); +``` + +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"; @@ -113,19 +138,7 @@ 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: "./plugins", }), ], routes: { @@ -134,17 +147,24 @@ export default defineConfig({ }); ``` +```ts title="server.ts" +import { createApp } from "@sentry/junior"; + +const app = await createApp(); + +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 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 [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..c9afc1953 100644 --- a/packages/junior-agent-browser/README.md +++ b/packages/junior-agent-browser/README.md @@ -8,4 +8,12 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-agent-browser ``` +Add the package name to the plugin set exported from `plugins.ts`: + +```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-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-datadog/README.md b/packages/junior-datadog/README.md index 975b24b7d..7c77c629d 100644 --- a/packages/junior-datadog/README.md +++ b/packages/junior-datadog/README.md @@ -8,14 +8,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..072fbe6a2 100644 --- a/packages/junior-github/README.md +++ b/packages/junior-github/README.md @@ -8,23 +8,18 @@ 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 plugin set exported from `plugins.ts`: ```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..35802b3e2 100644 --- a/packages/junior-hex/README.md +++ b/packages/junior-hex/README.md @@ -10,14 +10,12 @@ pnpm add @sentry/junior @sentry/junior-hex ## 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-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..0ce78225d 100644 --- a/packages/junior-linear/README.md +++ b/packages/junior-linear/README.md @@ -8,14 +8,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..d99b67c8b 100644 --- a/packages/junior-notion/README.md +++ b/packages/junior-notion/README.md @@ -8,14 +8,12 @@ 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 plugin set exported from `plugins.ts`: + +```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..b71e3178b 100644 --- a/packages/junior-sentry/README.md +++ b/packages/junior-sentry/README.md @@ -8,6 +8,14 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-sentry ``` +Add the package name to the plugin set exported from `plugins.ts`: + +```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-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/README.md b/packages/junior/README.md index f6892c08c..3d7a67c9a 100644 --- a/packages/junior/README.md +++ b/packages/junior/README.md @@ -25,7 +25,11 @@ 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([...])` 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/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..4dc6c2863 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,13 +17,31 @@ 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 runtime-safe plugin set. + +```ts +import { defineJuniorPlugins } from "@sentry/junior"; + +export const plugins = defineJuniorPlugins(["@acme/junior-my-provider"]); +``` + +Point `juniorNitro()` at the plugin module. `createApp()` reads that enabled +set from Nitro's virtual module. ```ts import { defineConfig } from "nitro"; @@ -32,9 +51,7 @@ export default defineConfig({ preset: "vercel", modules: [ juniorNitro({ - plugins: { - packages: ["@acme/junior-my-provider"], - }, + plugins: "./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(); ``` 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..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 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 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/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..a626b2786 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,27 @@ 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[]; + /** Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. */ + plugins?: JuniorPluginSet; waitUntil?: WaitUntilFn; } +interface JuniorVirtualConfig { + pluginSet?: JuniorPluginSet; + plugins?: PluginCatalogConfig; + trustedPluginRegistrations: string[]; +} + /** Build a `WaitUntilFn`, preferring Vercel's lifetime extension when available. */ async function defaultWaitUntil(): Promise { try { @@ -63,11 +76,21 @@ 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: { + pluginSet?: JuniorPluginSet; + plugins?: PluginCatalogConfig; + trustedPluginRegistrations?: string[]; + } = await import("#junior/config"); + return { + pluginSet: mod.pluginSet, + plugins: mod.plugins, + trustedPluginRegistrations: mod.trustedPluginRegistrations ?? [], + }; } catch (error) { if (!isMissingVirtualConfig(error)) { throw error; @@ -76,13 +99,8 @@ 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; - } - +/** Resolve plugin configuration from the env fallback. */ +function resolveEnvPluginCatalogConfig(): PluginCatalogConfig | undefined { const packages = readEnvPluginPackages(); if (packages) { return { packages }; @@ -130,62 +148,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(", ")}. Point juniorNitro({ plugins: "./plugins" }) at the runtime plugin module or 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 a runtime-safe plugin module to juniorNitro({ plugins: "./plugins" }) or pass the same defineJuniorPlugins(...) set to 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. */ @@ -213,21 +250,23 @@ 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 virtualConfig = await resolveVirtualConfig(); + const configuredPlugins = options?.plugins ?? virtualConfig?.pluginSet; + const agentPlugins = + trustedPluginRegistrationsFromPluginSet(configuredPlugins); + const pluginConfig = configuredPlugins + ? pluginCatalogConfigFromPluginSet(configuredPlugins) + : (virtualConfig?.plugins ?? resolveEnvPluginCatalogConfig()); + 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 +274,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..3dd313ecf 100644 --- a/packages/junior/src/build/virtual-config.ts +++ b/packages/junior/src/build/virtual-config.ts @@ -1,11 +1,67 @@ import type { Nitro } from "nitro/types"; -import type { PluginConfig } from "@/chat/plugins/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, - plugins?: PluginConfig, + options: { + loadPluginSet?: () => Promise; + pluginModule?: RuntimePluginModule; + plugins?: PluginCatalogConfig; + trustedPluginRegistrations?: string[]; + } = {}, ): void { - nitro.options.virtual["#junior/config"] = - `export const plugins = ${JSON.stringify(plugins ?? { packages: [] })};`; + 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/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/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 cc8d4d8bc..71843e7fe 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, @@ -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-]+)*$/; @@ -441,7 +442,7 @@ function mergeManifestConfig( function applyManifestConfig( source: ManifestSource, - config: PluginConfig | undefined, + config: PluginCatalogConfig | undefined, ): ManifestSource { const name = source.name; if (typeof name !== "string") { @@ -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?: PluginConfig, + 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/package-discovery.ts b/packages/junior/src/chat/plugins/package-discovery.ts index ddd93430e..54c903b0d 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[]; @@ -60,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[] = []; @@ -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..6ea54a59a 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 { @@ -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?: string, ): 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 ? { skillsDir } : {}), }; state.pluginDefinitions.push(definition); @@ -118,6 +120,20 @@ function registerPluginManifest( } } +function registerYamlPluginManifest( + state: LoadedPluginState, + raw: string, + pluginDir: string, +): void { + const manifest = parsePluginManifest(raw, pluginDir, pluginConfig); + registerPluginManifest( + state, + manifest, + pluginDir, + path.join(pluginDir, "skills"), + ); +} + function normalizePluginRoots(roots: string[]): string[] { const resolved: string[] = []; const seen = new Set(); @@ -143,10 +159,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 +175,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 +193,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 +211,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") + : undefined; + const manifest = parseInlinePluginManifest( + definition.manifest, + dir, + pluginConfig, + ); + registerPluginManifest(state, manifest, dir, skillsDir); + } +} + function discoverConfiguredPluginPackageContent(): InstalledPluginPackageContent { return discoverInstalledPluginPackageContent(process.cwd(), { packageNames: pluginConfig?.packages, @@ -200,6 +254,8 @@ function buildLoadedPluginState( state.packageSkillRoots.add(skillRoot); } + registerInlineManifests(state, source); + const roots = source.manifestRoots; for (const pluginsRoot of roots) { let entries: string[]; @@ -229,7 +285,7 @@ function buildLoadedPluginState( } if (hasRootManifest) { const rawRootManifest = readFileSync(manifestPath, "utf8"); - registerPluginManifest(state, rawRootManifest, pluginsRoot); + registerYamlPluginManifest(state, rawRootManifest, pluginsRoot); continue; } } @@ -266,7 +322,7 @@ function buildLoadedPluginState( continue; // No manifest — skip } - registerPluginManifest(state, raw, pluginDir); + registerYamlPluginManifest(state, raw, pluginDir); } } @@ -299,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", ); @@ -321,11 +379,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; } @@ -455,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, ]), ]; @@ -468,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 2304d92b5..0c01878a0 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; } @@ -170,5 +171,10 @@ export interface PluginBrokerDeps { export interface PluginDefinition { manifest: PluginManifest; dir: string; - skillsDir: 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..a37013e3f 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 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 point juniorNitro({ plugins: "./plugins" }) at it.`, ); } diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index 5b66b6358..a04757bf9 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -1,18 +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 type { PluginConfig } from "@/chat/plugins/types"; +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 packages and manifest overrides bundled into the app. */ - plugins?: PluginConfig; + /** 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 * bundler cannot trace (e.g. dynamically imported providers). @@ -22,6 +44,141 @@ 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}`, + ); +} + +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 }; @@ -39,13 +196,48 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { options.maxDuration ?? 800; applyRolldownTreeshakeWorkaround(nitro); - injectVirtualConfig(nitro, options.plugins); + const pluginSource = options.plugins; + const pluginModule = isPluginModuleReference(pluginSource) + ? resolvePluginModule(cwd, pluginSource) + : undefined; + const directPluginSet = isPluginSet(pluginSource) + ? pluginSource + : undefined; + if (directPluginSet) { + assertSerializableDirectPluginSet(directPluginSet); + } + let pluginSetPromise: Promise | undefined; + const loadConfiguredPluginSet = () => { + pluginSetPromise ??= pluginModule + ? loadPluginSetFromModule(pluginModule) + : Promise.resolve(directPluginSet); + return pluginSetPromise; + }; + const pluginCatalogConfig = + pluginCatalogConfigFromPluginSet(directPluginSet); + const trustedPluginRegistrations = + trustedPluginRegistrationsFromPluginSet(directPluginSet).map( + (plugin) => plugin.name, + ); + injectVirtualConfig(nitro, { + ...(pluginModule + ? { + loadPluginSet: loadConfiguredPluginSet, + pluginModule: pluginModule.runtimeModule, + } + : {}), + plugins: pluginCatalogConfig, + trustedPluginRegistrations, + }); - nitro.hooks.hook("compiled", () => { + nitro.hooks.hook("compiled", async () => { + const pluginSet = await loadConfiguredPluginSet(); + const compiledPluginCatalogConfig = + pluginCatalogConfigFromPluginSet(pluginSet); copyAppAndPluginContent( cwd, nitro.options.output.serverDir, - options.plugins?.packages, + compiledPluginCatalogConfig?.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..48d9c77a3 100644 --- a/packages/junior/src/virtual-modules.d.ts +++ b/packages/junior/src/virtual-modules.d.ts @@ -1,6 +1,9 @@ /** 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"; + import type { JuniorPluginSet } from "@/plugins"; - export const plugins: PluginConfig; + 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/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/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 0c13a2b2a..8b30ade53 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -2,14 +2,18 @@ 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 { createApp } from "@/app"; +import { afterEach, describe, expect, it, vi } from "vitest"; +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 { + getPluginSkillRoots, + getPluginProviders, + setPluginCatalogConfig, +} from "@/chat/plugins/registry"; const originalCwd = process.cwd(); const originalPluginPackages = process.env.JUNIOR_PLUGIN_PACKAGES; @@ -27,6 +31,7 @@ async function writePluginPackage( root: string, packageName: string, pluginName: string, + extraLines: string[] = [], ): Promise { const packageRoot = path.join( root, @@ -39,6 +44,7 @@ async function writePluginPackage( [ `name: ${pluginName}`, `description: ${pluginName} plugin`, + ...extraLines, "config-keys:", " - org", ].join("\n"), @@ -49,8 +55,9 @@ async function writePluginPackage( afterEach(async () => { process.chdir(originalCwd); setAgentPlugins([]); - setPluginConfig(undefined); + setPluginCatalogConfig(undefined); setConfigDefaults(undefined); + vi.doUnmock("#junior/config"); if (originalPluginPackages === undefined) { delete process.env.JUNIOR_PLUGIN_PACKAGES; } else { @@ -84,14 +91,14 @@ describe("createApp plugin config", () => { process.env.JUNIOR_PLUGIN_PACKAGES = "not-json"; await createApp({ - plugins: [], + plugins: defineJuniorPlugins([]), }); expect(getPluginProviders()).toEqual([]); 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( @@ -106,14 +113,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([ @@ -124,23 +140,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 +164,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 +200,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 +219,196 @@ 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("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( + [ + 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([ + 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/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 +416,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 +447,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 +465,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/build/nitro-plugin-module.test.ts b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts new file mode 100644 index 000000000..42f7a06e1 --- /dev/null +++ b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts @@ -0,0 +1,160 @@ +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[] = []; + +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("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> = {}; + 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( + 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/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..86e9370c3 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 { @@ -172,6 +181,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -203,6 +216,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -229,6 +246,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -255,6 +276,10 @@ describe("agent plugin hooks", () => { const previous = setAgentPlugins([ defineJuniorPlugin({ name: "agent-demo", + manifest: { + name: "agent-demo", + description: "Agent demo", + }, hooks: { routes() { return [ @@ -286,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)" }; @@ -306,7 +335,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-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/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/dashboard.md b/specs/dashboard.md index 56a8c5f90..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. @@ -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-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..f832dfca6 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,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 trusted plugin factories to `createApp({ plugins })`, and `juniorNitro({ plugins })` owns build-time copying of bundled plugin content. +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 702553d48..bc6388571 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 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. ## File Shape 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.