From 7bd34d0ea1a2936d1180c87156b18ade99fb3c2b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 14:45:05 -0700 Subject: [PATCH 1/6] feat(example): Enable scheduler plugin introspection Add the scheduler plugin to the example app and teach jr-rpc to list installed plugins so Junior can inspect its own runtime catalog. Local chat now hydrates the app plugin set before running, which keeps apps/example local QA aligned with server plugin wiring. Update the example env docs and build guards for scheduler's SQL-backed package, and cover the new jr-rpc command plus example build discovery in focused tests. Co-Authored-By: GPT-5 Codex --- apps/example/.env.example | 2 + apps/example/README.md | 3 +- apps/example/package.json | 5 +- apps/example/plugins.ts | 2 + packages/junior/skills/jr-rpc/SKILL.md | 12 +++- .../src/chat/capabilities/jr-rpc-command.ts | 41 ++++++++++++ packages/junior/src/chat/prompt.ts | 3 +- packages/junior/src/cli/chat.ts | 63 +++++++++++++++++++ .../example-build-discovery.test.ts | 3 + .../unit/handlers/jr-rpc-command.test.ts | 47 ++++++++++++++ pnpm-lock.yaml | 3 + 11 files changed, 178 insertions(+), 6 deletions(-) diff --git a/apps/example/.env.example b/apps/example/.env.example index 8d15398cb..2e6dfb71c 100644 --- a/apps/example/.env.example +++ b/apps/example/.env.example @@ -1,6 +1,8 @@ SLACK_BOT_TOKEN= SLACK_SIGNING_SECRET= JUNIOR_SECRET= +DATABASE_URL= +JUNIOR_DATABASE_URL= REDIS_URL= JUNIOR_BOT_NAME=junior-example GITHUB_APP_ID= diff --git a/apps/example/README.md b/apps/example/README.md index 63b388a34..eeaca6283 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-datadog`, `@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-scheduler`, `@sentry/junior-sentry`, `@sentry/junior-vercel`) ## Run @@ -28,6 +28,7 @@ Copy `.env.example` and set: - `SLACK_BOT_TOKEN` - `SLACK_SIGNING_SECRET` +- `DATABASE_URL` or `JUNIOR_DATABASE_URL` - `REDIS_URL` - `AI_MODEL` (optional) - `AI_FAST_MODEL` (optional) diff --git a/apps/example/package.json b/apps/example/package.json index 115379362..47cb0f1b1 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "scripts": { - "predev": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build", + "predev": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior-scheduler build", "dev": "nitro dev", - "prebuild": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build", + "prebuild": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior-scheduler build", "build": "junior snapshot create && nitro build", "postbuild": "node scripts/check-vercel-output.mjs", "preview": "nitro preview", @@ -20,6 +20,7 @@ "@sentry/junior-hex": "workspace:*", "@sentry/junior-linear": "workspace:*", "@sentry/junior-notion": "workspace:*", + "@sentry/junior-scheduler": "workspace:*", "@sentry/junior-sentry": "workspace:*", "@sentry/junior-vercel": "workspace:*", "hono": "^4.12.22" diff --git a/apps/example/plugins.ts b/apps/example/plugins.ts index 85f78fc65..df28fce13 100644 --- a/apps/example/plugins.ts +++ b/apps/example/plugins.ts @@ -1,6 +1,7 @@ import { defineJuniorPlugins } from "@sentry/junior"; import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; import { githubPlugin } from "@sentry/junior-github"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; import { exampleDashboardAuthRequired, exampleDashboardMockConversations, @@ -25,6 +26,7 @@ export const plugins = defineJuniorPlugins([ "@sentry/junior-hex", "@sentry/junior-linear", "@sentry/junior-notion", + schedulerPlugin(), "@sentry/junior-sentry", "@sentry/junior-vercel", ]); diff --git a/packages/junior/skills/jr-rpc/SKILL.md b/packages/junior/skills/jr-rpc/SKILL.md index 90438d9c7..b57a00405 100644 --- a/packages/junior/skills/jr-rpc/SKILL.md +++ b/packages/junior/skills/jr-rpc/SKILL.md @@ -1,12 +1,20 @@ --- name: jr-rpc -description: Manage low-level config flows via jr-rpc bash commands, including setting a default GitHub repo. Use only when the user explicitly asks to read or update provider defaults/config. Do not use for PR, branch, push, or auth-order questions; load the matching provider skill instead. +description: Manage low-level config and plugin introspection flows via jr-rpc bash commands. Use only when the user explicitly asks to read or update provider defaults/config or list installed plugins. Do not use for PR, branch, push, or auth-order questions; load the matching provider skill instead. allowed-tools: bash --- # jr-rpc -Manage low-level config flows for the current agent turn. +Manage low-level config and plugin introspection flows for the current agent turn. + +## Plugins + +`jr-rpc plugins list` — list installed plugins visible to the current Junior runtime. + +Command syntax: + +- `jr-rpc plugins list` ## Configuration diff --git a/packages/junior/src/chat/capabilities/jr-rpc-command.ts b/packages/junior/src/chat/capabilities/jr-rpc-command.ts index 3e7656c76..213ce3d34 100644 --- a/packages/junior/src/chat/capabilities/jr-rpc-command.ts +++ b/packages/junior/src/chat/capabilities/jr-rpc-command.ts @@ -1,6 +1,7 @@ import { Bash, defineCommand } from "just-bash"; import type { ChannelConfigurationService } from "@/chat/configuration/types"; import { logInfo } from "@/chat/logging"; +import { getPluginProviders } from "@/chat/plugins/registry"; import type { Skill } from "@/chat/skills"; type JrRpcDeps = { @@ -256,6 +257,42 @@ async function handleConfigCommand( }); } +async function handlePluginsCommand( + args: string[], +): Promise> { + const usage = "jr-rpc plugins list"; + const subverb = (args[0] ?? "").trim(); + if (subverb !== "list" || args.length !== 1) { + return commandResult({ + stderr: `Usage:\n${usage}\n`, + exitCode: 2, + }); + } + + const plugins = getPluginProviders() + .map((plugin) => ({ + name: plugin.manifest.name, + displayName: plugin.manifest.displayName, + description: plugin.manifest.description, + capabilities: [...plugin.manifest.capabilities], + configKeys: [...plugin.manifest.configKeys], + hasCredentials: Boolean(plugin.manifest.credentials), + hasMcp: Boolean(plugin.manifest.mcp), + hasOAuth: Boolean(plugin.manifest.oauth), + hasSkills: Boolean(plugin.skillsDir), + hasMigrations: Boolean(plugin.migrationsDir), + })) + .sort((left, right) => left.name.localeCompare(right.name)); + + return commandResult({ + stdout: { + ok: true, + plugins, + }, + exitCode: 0, + }); +} + function createJrRpcCommand(deps: JrRpcDeps) { return defineCommand("jr-rpc", async (args) => { const usage = [ @@ -263,11 +300,15 @@ function createJrRpcCommand(deps: JrRpcDeps) { "jr-rpc config set [--json]", "jr-rpc config unset ", "jr-rpc config list [--prefix ]", + "jr-rpc plugins list", ].join("\n"); const verb = (args[0] ?? "").trim(); if (verb === "config") { return handleConfigCommand(args.slice(1), deps); } + if (verb === "plugins") { + return handlePluginsCommand(args.slice(1)); + } return commandResult({ stderr: `Unsupported jr-rpc command. Use:\n${usage}\n`, exitCode: 2, diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index abeda1c80..e04489dff 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -349,6 +349,7 @@ const TOOL_POLICY_RULES = [ `- Sandbox-backed file and shell tools operate in an isolated workspace rooted at ${SANDBOX_WORKSPACE_ROOT}; readFile/writeFile paths are sandbox-workspace paths, bash runs inside that workspace, and attachFile accepts absolute or workspace-relative sandbox paths.`, "- If a sandbox-backed tool reports that sandbox execution is unavailable, treat that as a blocker for local file/shell inspection; do not pretend host files were inspected.", "- For user-provided URLs, use `webFetch`; for discovery, use `webSearch` then fetch/read promising sources; for current time/date context, use `systemTime`.", + "- Run `jr-rpc config get|set|unset|list` for provider defaults and `jr-rpc plugins list` for installed plugin introspection as standalone bash commands; do not chain them with `cd`, `&&`, pipes, or provider commands.", "- If the first result is empty, stale, ambiguous, or incomplete, try a focused alternate query, path, command, or source before concluding the answer cannot be verified.", ]; @@ -526,7 +527,7 @@ function buildContextSection(params: { if (configLines) { blocks.push( renderTag("configuration", [ - "Ambient provider defaults; explicit targets win. Run `jr-rpc config get|set|unset|list` as standalone bash commands; do not chain with `cd`, `&&`, pipes, or provider commands.", + "Ambient provider defaults; explicit targets win.", ...configLines, ]), ); diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 78770c3ec..3481e585f 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -11,8 +11,19 @@ import { stdout as defaultStdout, } from "node:process"; import { randomUUID } from "node:crypto"; +import { statSync } from "node:fs"; +import path from "node:path"; import * as readline from "node:readline/promises"; +import { pathToFileURL } from "node:url"; +import { + pluginCatalogConfigFromEnv, + pluginCatalogConfigFromPluginSet, + pluginHookRegistrationsFromPluginSet, + type JuniorPluginSet, +} from "@/plugins"; import { normalizeLocalConversationId } from "@/chat/local/conversation"; +import { setPlugins } from "@/chat/plugins/agent-hooks"; +import { setPluginCatalogConfig } from "@/chat/plugins/registry"; import type { LocalAgentReply } from "@/chat/local/runner"; export const CHAT_USAGE = "usage: junior chat\n junior chat -p "; @@ -97,6 +108,56 @@ function defaultStateAdapterForLocalChat(): void { process.env.JUNIOR_STATE_ADAPTER = "memory"; } +function isFile(targetPath: string): boolean { + try { + return statSync(targetPath).isFile(); + } catch { + return false; + } +} + +function isPluginSet(value: unknown): value is JuniorPluginSet { + return ( + Boolean(value) && + typeof value === "object" && + Array.isArray((value as Partial).packageNames) && + Array.isArray((value as Partial).registrations) + ); +} + +function localPluginModuleCandidates(cwd = process.cwd()): string[] { + return ["plugins.js", "plugins.mjs", "plugins.ts"] + .map((fileName) => path.resolve(cwd, fileName)) + .filter(isFile); +} + +async function loadLocalPluginSet(): Promise { + for (const pluginModulePath of localPluginModuleCandidates()) { + const mod = (await import(pathToFileURL(pluginModulePath).href)) as Record< + string, + unknown + >; + const pluginSet = mod.plugins ?? mod.default; + if (!isPluginSet(pluginSet)) { + throw new Error( + `${pluginModulePath} must export a defineJuniorPlugins(...) set as "plugins" or default`, + ); + } + return pluginSet; + } + return undefined; +} + +async function configureLocalChatPlugins(): Promise { + const pluginSet = await loadLocalPluginSet(); + setPluginCatalogConfig( + pluginSet + ? pluginCatalogConfigFromPluginSet(pluginSet) + : pluginCatalogConfigFromEnv(), + ); + setPlugins(pluginHookRegistrationsFromPluginSet(pluginSet)); +} + function parseChatArgs(argv: string[]): ChatCommandOptions | undefined { if (argv.length === 0) { return { mode: "interactive" }; @@ -140,6 +201,7 @@ async function runPrompt( io: ChatIo, ): Promise { defaultStateAdapterForLocalChat(); + await configureLocalChatPlugins(); const conversationId = newRunConversationId(); const { runLocalAgentTurn } = await import("@/chat/local/runner"); @@ -162,6 +224,7 @@ async function runPrompt( async function runInteractive(io: ChatIo): Promise { defaultStateAdapterForLocalChat(); + await configureLocalChatPlugins(); const conversationId = newRunConversationId(); const { runLocalAgentTurn } = await import("@/chat/local/runner"); diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index 41d04643a..b842a85be 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -176,6 +176,9 @@ describe.sequential("example build discovery integration", () => { expect(packageJson.scripts?.prebuild).toContain( "pnpm --filter @sentry/junior-dashboard build", ); + expect(packageJson.scripts?.prebuild).toContain( + "pnpm --filter @sentry/junior-scheduler build", + ); expect(packageJson.scripts?.postbuild).toBe( "node scripts/check-vercel-output.mjs", ); diff --git a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts index b93cf9465..98773d800 100644 --- a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts +++ b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; import { createChannelConfigurationService } from "@/chat/configuration/service"; +import { setPluginCatalogConfig } from "@/chat/plugins/registry"; import type { Skill } from "@/chat/skills"; const activeSkill: Skill = { @@ -134,6 +135,52 @@ describe("jr-rpc custom command", () => { }); }); + it("lists installed plugins", async () => { + const previousConfig = setPluginCatalogConfig({ + inlineManifests: [ + { + manifest: { + name: "example", + displayName: "Example", + description: "Example plugin", + capabilities: ["example.search"], + configKeys: ["example.repo"], + }, + }, + ], + }); + try { + const result = await maybeExecuteJrRpcCustomCommand( + "jr-rpc plugins list", + { + activeSkill, + }, + ); + const handled = expectHandled(result); + expect(handled.result.exit_code).toBe(0); + const output = JSON.parse(handled.result.stdout); + expect(output.ok).toBe(true); + expect(output.plugins).toEqual( + expect.arrayContaining([ + { + name: "example", + displayName: "Example", + description: "Example plugin", + capabilities: ["example.search"], + configKeys: ["example.repo"], + hasCredentials: false, + hasMcp: false, + hasOAuth: false, + hasSkills: false, + hasMigrations: false, + }, + ]), + ); + } finally { + setPluginCatalogConfig(previousConfig); + } + }); + it("unsets configuration values", async () => { const configuration = makeChannelConfiguration(); const onConfigurationValueChanged = vi.fn(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 898d6e2b9..a26a90d1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: "@sentry/junior-notion": specifier: workspace:* version: link:../../packages/junior-notion + "@sentry/junior-scheduler": + specifier: workspace:* + version: file:packages/junior-scheduler(@neondatabase/serverless@1.1.0) "@sentry/junior-sentry": specifier: workspace:* version: link:../../packages/junior-sentry From a0b4f2b948a91bac8639a322da4f6713d5606ed3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 15:13:39 -0700 Subject: [PATCH 2/6] test: Stabilize scheduler-backed example tests Configure the example discovery suite with the SQL URL required by scheduler's database-backed plugin registration. Give PGlite-backed upgrade migration tests enough runtime budget under coverage. Co-Authored-By: GPT-5 Codex --- .../tests/component/cli/upgrade-cli.test.ts | 6 +++--- .../integration/example-build-discovery.test.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index 56d498cbc..a3fe80be1 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -196,7 +196,7 @@ describe("upgrade CLI migrations", () => { } finally { await fixture.close(); } - }); + }, 15_000); it("copies a bounded SQL conversation backfill slice", async () => { const stateAdapter = getStateAdapter(); @@ -236,7 +236,7 @@ describe("upgrade CLI migrations", () => { } finally { await fixture.close(); } - }); + }, 15_000); it("seeds active awaiting continuations into conversation work", async () => { const stateAdapter = getStateAdapter(); @@ -508,5 +508,5 @@ describe("upgrade CLI migrations", () => { } finally { await fixture.close(); } - }); + }, 15_000); }); diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index b842a85be..0e7f8461e 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -9,7 +9,15 @@ import { import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; const originalEnv = { ...process.env }; const originalCwd = process.cwd(); @@ -20,6 +28,7 @@ const examplePluginsModule = path.join(exampleRoot, "plugins.ts"); const exampleDashboardConfig = path.join(exampleRoot, "dashboard.ts"); const examplePackageJson = path.join(exampleRoot, "package.json"); const exampleRequire = createRequire(exampleEntry); +const exampleDatabaseUrl = "postgres://configured.example.test/neon"; const vercelEnvNames = [ "VERCEL", "VERCEL_ENV", @@ -107,6 +116,10 @@ describe.sequential("example build discovery integration", () => { buildJuniorPackage(); }, 60_000); + beforeEach(() => { + process.env.JUNIOR_DATABASE_URL ??= exampleDatabaseUrl; + }); + afterEach(() => { process.chdir(originalCwd); process.env = { ...originalEnv }; From dde5ceced8895bab3bef3864dd5b13df08be6455 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 15:19:57 -0700 Subject: [PATCH 3/6] fix(cli): Validate database plugins in local chat Local chat now applies the same SQL requirement check as app startup before registering hook plugins. This keeps scheduler-backed plugin sets from running without DATABASE_URL or JUNIOR_DATABASE_URL. Co-Authored-By: GPT-5 Codex --- packages/junior/src/cli/chat.ts | 6 ++- .../junior/tests/unit/cli/chat-cli.test.ts | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 3481e585f..117378ef2 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -150,12 +150,16 @@ async function loadLocalPluginSet(): Promise { async function configureLocalChatPlugins(): Promise { const pluginSet = await loadLocalPluginSet(); + const plugins = pluginHookRegistrationsFromPluginSet(pluginSet); + const { validatePluginDatabaseRequirements } = + await import("@/chat/plugins/db"); + validatePluginDatabaseRequirements(plugins); setPluginCatalogConfig( pluginSet ? pluginCatalogConfigFromPluginSet(pluginSet) : pluginCatalogConfigFromEnv(), ); - setPlugins(pluginHookRegistrationsFromPluginSet(pluginSet)); + setPlugins(plugins); } function parseChatArgs(argv: string[]): ChatCommandOptions | undefined { diff --git a/packages/junior/tests/unit/cli/chat-cli.test.ts b/packages/junior/tests/unit/cli/chat-cli.test.ts index 3a6455eec..ccfc99514 100644 --- a/packages/junior/tests/unit/cli/chat-cli.test.ts +++ b/packages/junior/tests/unit/cli/chat-cli.test.ts @@ -1,4 +1,7 @@ import { PassThrough, Writable } from "node:stream"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CHAT_USAGE, runChat } from "@/cli/chat"; import type { @@ -16,6 +19,9 @@ vi.mock("@/chat/local/runner", () => ({ const ORIGINAL_STATE_ADAPTER = process.env.JUNIOR_STATE_ADAPTER; const ORIGINAL_REDIS_URL = process.env.REDIS_URL; +const ORIGINAL_DATABASE_URL = process.env.DATABASE_URL; +const ORIGINAL_JUNIOR_DATABASE_URL = process.env.JUNIOR_DATABASE_URL; +const ORIGINAL_CWD = process.cwd(); function reply(outcome: LocalAgentTurnResult["outcome"]): { conversationId: string; @@ -37,8 +43,12 @@ describe("chat cli", () => { }); afterEach(() => { + process.chdir(ORIGINAL_CWD); restoreEnv("JUNIOR_STATE_ADAPTER", ORIGINAL_STATE_ADAPTER); restoreEnv("REDIS_URL", ORIGINAL_REDIS_URL); + restoreEnv("DATABASE_URL", ORIGINAL_DATABASE_URL); + restoreEnv("JUNIOR_DATABASE_URL", ORIGINAL_JUNIOR_DATABASE_URL); + vi.resetModules(); }); it("returns usage for invalid argument forms", async () => { @@ -108,6 +118,48 @@ describe("chat cli", () => { expect(process.env.JUNIOR_STATE_ADAPTER).toBe("memory"); }); + it("fails local chat for database plugins without SQL configuration", async () => { + delete process.env.DATABASE_URL; + delete process.env.JUNIOR_DATABASE_URL; + const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); + writeFileSync( + path.join(tempDir, "plugins.mjs"), + `export const plugins = { + packageNames: [], + registrations: [ + { + database: {}, + manifest: { + name: "database-plugin", + displayName: "Database Plugin", + description: "Database-backed local chat test plugin", + }, + }, + ], +}; +`, + ); + process.chdir(tempDir); + + try { + const io = { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }; + + expect(await runChat(["-p", "hello"], io)).toBe(1); + expect(io.error).toHaveBeenCalledWith( + "Plugin database access requires JUNIOR_DATABASE_URL or DATABASE_URL for: database-plugin", + ); + expect(runner.runLocalAgentTurn).not.toHaveBeenCalled(); + } finally { + process.chdir(ORIGINAL_CWD); + rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("preserves an explicit prompt local chat state adapter", async () => { process.env.JUNIOR_STATE_ADAPTER = "redis"; process.env.REDIS_URL = "redis://localhost:6379"; From 37105f158b3e19d3ce70f03b740dd360be6a6d30 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 15:21:18 -0700 Subject: [PATCH 4/6] test: Set SQL URL in Vitest setup Bind a deterministic Junior SQL URL for package tests so scheduler-backed app imports do not need suite-local database setup. Co-Authored-By: GPT-5 Codex --- .../integration/example-build-discovery.test.ts | 15 +-------------- packages/junior/vitest.config.ts | 1 + 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index 0e7f8461e..b842a85be 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -9,15 +9,7 @@ import { import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const originalEnv = { ...process.env }; const originalCwd = process.cwd(); @@ -28,7 +20,6 @@ const examplePluginsModule = path.join(exampleRoot, "plugins.ts"); const exampleDashboardConfig = path.join(exampleRoot, "dashboard.ts"); const examplePackageJson = path.join(exampleRoot, "package.json"); const exampleRequire = createRequire(exampleEntry); -const exampleDatabaseUrl = "postgres://configured.example.test/neon"; const vercelEnvNames = [ "VERCEL", "VERCEL_ENV", @@ -116,10 +107,6 @@ describe.sequential("example build discovery integration", () => { buildJuniorPackage(); }, 60_000); - beforeEach(() => { - process.env.JUNIOR_DATABASE_URL ??= exampleDatabaseUrl; - }); - afterEach(() => { process.chdir(originalCwd); process.env = { ...originalEnv }; diff --git a/packages/junior/vitest.config.ts b/packages/junior/vitest.config.ts index 01e709dff..101451832 100644 --- a/packages/junior/vitest.config.ts +++ b/packages/junior/vitest.config.ts @@ -23,6 +23,7 @@ for (const envRoot of [workspaceRoot, packageRoot]) { } process.env.JUNIOR_SECRET = "junior-test-secret"; +process.env.JUNIOR_DATABASE_URL = "postgres://user:pass@example.test/neon"; process.env.JUNIOR_STATE_ADAPTER = "memory"; process.env.JUNIOR_STATE_KEY_PREFIX ??= `junior:test:${process.pid}`; From 160154f73c958c995eaed245e0ae3a67243fc6a1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 15:27:40 -0700 Subject: [PATCH 5/6] fix(cli): Load TypeScript plugin modules Use jiti for local chat plugin loading so generated plugins.ts files work from the published CLI without relying on an external TypeScript loader. Co-Authored-By: GPT-5 Codex --- packages/junior/package.json | 1 + packages/junior/src/cli/chat.ts | 9 +++-- .../junior/tests/unit/cli/chat-cli.test.ts | 35 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/junior/package.json b/packages/junior/package.json index 8b2aee791..dd7907cbc 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -79,6 +79,7 @@ "chat": "4.29.0", "drizzle-orm": "catalog:", "hono": "^4.12.22", + "jiti": "^2.7.0", "jose": "^6.2.3", "just-bash": "3.0.1", "node-html-markdown": "^2.0.0", diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 117378ef2..362640e42 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto"; import { statSync } from "node:fs"; import path from "node:path"; import * as readline from "node:readline/promises"; -import { pathToFileURL } from "node:url"; +import { createJiti } from "jiti"; import { pluginCatalogConfigFromEnv, pluginCatalogConfigFromPluginSet, @@ -45,6 +45,7 @@ const DEFAULT_IO: ChatIo = { output: defaultStdout, write: (text) => writeStream(defaultStdout, text), }; +const localPluginLoader = createJiti(import.meta.url, { moduleCache: false }); class ChatOutputError extends Error { constructor(error: unknown) { @@ -133,10 +134,8 @@ function localPluginModuleCandidates(cwd = process.cwd()): string[] { async function loadLocalPluginSet(): Promise { for (const pluginModulePath of localPluginModuleCandidates()) { - const mod = (await import(pathToFileURL(pluginModulePath).href)) as Record< - string, - unknown - >; + const mod = + await localPluginLoader.import>(pluginModulePath); const pluginSet = mod.plugins ?? mod.default; if (!isPluginSet(pluginSet)) { throw new Error( diff --git a/packages/junior/tests/unit/cli/chat-cli.test.ts b/packages/junior/tests/unit/cli/chat-cli.test.ts index ccfc99514..4aec96a12 100644 --- a/packages/junior/tests/unit/cli/chat-cli.test.ts +++ b/packages/junior/tests/unit/cli/chat-cli.test.ts @@ -160,6 +160,41 @@ describe("chat cli", () => { } }); + it("loads TypeScript local plugin modules before prompt local chat", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); + writeFileSync( + path.join(tempDir, "plugins.ts"), + `const packageNames: string[] = ["@sentry/test-plugin"]; + +export const plugins = { + packageNames, + registrations: [], +}; +`, + ); + process.chdir(tempDir); + runner.runLocalAgentTurn.mockImplementation(async (_input, deps) => { + const result = reply("success"); + await deps.deliverReply(result.reply); + return result; + }); + + try { + const io = { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }; + + expect(await runChat(["-p", "hello"], io)).toBe(0); + expect(runner.runLocalAgentTurn).toHaveBeenCalledOnce(); + } finally { + process.chdir(ORIGINAL_CWD); + rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("preserves an explicit prompt local chat state adapter", async () => { process.env.JUNIOR_STATE_ADAPTER = "redis"; process.env.REDIS_URL = "redis://localhost:6379"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a26a90d1e..ae0a3014e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: hono: specifier: ^4.12.22 version: 4.12.22 + jiti: + specifier: ^2.7.0 + version: 2.7.0 jose: specifier: ^6.2.3 version: 6.2.3 From ed964875ef65c9a6d253412bf768f603e424f624 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Jun 2026 22:23:20 -0700 Subject: [PATCH 6/6] fix(cli): Validate local chat plugin catalog Load the configured plugin catalog during local chat startup so unresolved plugin packages and registration mismatches fail before the local runner starts. Share the app startup validation helpers with the CLI path. Keep the scheduler example SQL URL scoped to the example integration suite so unrelated tests continue to exercise non-SQL defaults. Co-Authored-By: GPT-5 Codex --- packages/junior/src/app.ts | 77 ++----------------- .../junior/src/chat/plugins/validation.ts | 74 ++++++++++++++++++ packages/junior/src/cli/chat.ts | 37 ++++++--- .../example-build-discovery.test.ts | 15 +++- .../junior/tests/unit/cli/chat-cli.test.ts | 45 ++++++++++- packages/junior/vitest.config.ts | 1 - 6 files changed, 164 insertions(+), 85 deletions(-) create mode 100644 packages/junior/src/chat/plugins/validation.ts diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 0da4e13e0..ef6a0fa9a 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -9,7 +9,6 @@ import { generateAssistantReply } from "@/chat/respond"; import { normalizeSandboxEgressTracePropagationDomains } from "@/chat/sandbox/egress-tracing"; import { getPluginCatalogSignature, - getPluginProviders, setPluginCatalogConfig, } from "@/chat/plugins/registry"; import { @@ -20,9 +19,13 @@ import { } from "@/chat/plugins/agent-hooks"; import { validatePluginDatabaseRequirements } from "@/chat/plugins/db"; import type { PluginCatalogConfig } from "@/chat/plugins/types"; +import { + validatePluginEgressCredentialHooks, + validatePluginRegistrations, +} from "@/chat/plugins/validation"; import type { - PluginRouteMethod, PluginRegistration, + PluginRouteMethod, } from "@sentry/junior-plugin-api"; import { pluginCatalogConfigFromEnv, @@ -205,76 +208,6 @@ function validateBuildIncludesPluginHookRegistrations( ); } -function validatePluginRegistrations( - registrations: PluginRegistration[], -): void { - const loadedPlugins = getPluginProviders(); - const loadedNames = new Set( - loadedPlugins.map((plugin) => plugin.manifest.name), - ); - - for (const registration of registrations) { - if (!loadedNames.has(registration.manifest.name)) { - throw new Error( - `Plugin registration "${registration.manifest.name}" does not have a matching plugin manifest. Add an inline manifest, packageName, or app-local plugin.yaml with the same name.`, - ); - } - } -} - -function validatePluginEgressCredentialHooks( - registrations: PluginRegistration[], -): void { - const plugins = new Map( - registrations.map((registration) => [ - registration.manifest.name, - registration, - ]), - ); - - for (const provider of getPluginProviders()) { - const hooks = plugins.get(provider.manifest.name)?.hooks; - const hasGrantHook = Boolean(hooks?.grantForEgress); - const hasIssueHook = Boolean(hooks?.issueCredential); - const hasGenericCredentials = Boolean( - provider.manifest.credentials || provider.manifest.apiHeaders, - ); - const hasDomains = Boolean(provider.manifest.domains?.length); - const hasHookManagedOAuth = Boolean( - provider.manifest.oauth && !provider.manifest.credentials, - ); - if (!hasGrantHook && !hasIssueHook) { - if (hasDomains && !hasGenericCredentials) { - throw new Error( - `Plugin "${provider.manifest.name}" manifest.domains requires egress credential hooks when no generic credentials or apiHeaders are configured.`, - ); - } - if (hasHookManagedOAuth) { - throw new Error( - `Plugin "${provider.manifest.name}" manifest.oauth without oauth-bearer credentials requires egress credential hooks.`, - ); - } - continue; - } - - if (!hasGrantHook || !hasIssueHook) { - throw new Error( - `Plugin "${provider.manifest.name}" egress credential hooks must include both grantForEgress and issueCredential.`, - ); - } - if (hasGenericCredentials) { - throw new Error( - `Plugin "${provider.manifest.name}" egress credential hooks must use manifest.domains instead of generic credentials or apiHeaders.`, - ); - } - if (!hasDomains) { - throw new Error( - `Plugin "${provider.manifest.name}" egress credential hooks require manifest.domains to list sandbox egress hosts.`, - ); - } - } -} - /** Mount plugin HTTP handlers before core routes claim those paths. */ function mountPluginRoutes(app: Hono, routes: PluginRouteRegistration[]): void { for (const route of routes) { diff --git a/packages/junior/src/chat/plugins/validation.ts b/packages/junior/src/chat/plugins/validation.ts new file mode 100644 index 000000000..8f0e2d4f1 --- /dev/null +++ b/packages/junior/src/chat/plugins/validation.ts @@ -0,0 +1,74 @@ +import type { PluginRegistration } from "@sentry/junior-plugin-api"; +import { getPluginProviders } from "@/chat/plugins/registry"; + +/** Validate hook registrations against the loaded plugin manifest catalog. */ +export function validatePluginRegistrations( + registrations: PluginRegistration[], +): void { + const loadedPlugins = getPluginProviders(); + const loadedNames = new Set( + loadedPlugins.map((plugin) => plugin.manifest.name), + ); + + for (const registration of registrations) { + if (!loadedNames.has(registration.manifest.name)) { + throw new Error( + `Plugin registration "${registration.manifest.name}" does not have a matching plugin manifest. Add an inline manifest, packageName, or app-local plugin.yaml with the same name.`, + ); + } + } +} + +/** Validate credential hook registrations against the loaded plugin manifests. */ +export function validatePluginEgressCredentialHooks( + registrations: PluginRegistration[], +): void { + const plugins = new Map( + registrations.map((registration) => [ + registration.manifest.name, + registration, + ]), + ); + + for (const provider of getPluginProviders()) { + const hooks = plugins.get(provider.manifest.name)?.hooks; + const hasGrantHook = Boolean(hooks?.grantForEgress); + const hasIssueHook = Boolean(hooks?.issueCredential); + const hasGenericCredentials = Boolean( + provider.manifest.credentials || provider.manifest.apiHeaders, + ); + const hasDomains = Boolean(provider.manifest.domains?.length); + const hasHookManagedOAuth = Boolean( + provider.manifest.oauth && !provider.manifest.credentials, + ); + if (!hasGrantHook && !hasIssueHook) { + if (hasDomains && !hasGenericCredentials) { + throw new Error( + `Plugin "${provider.manifest.name}" manifest.domains requires egress credential hooks when no generic credentials or apiHeaders are configured.`, + ); + } + if (hasHookManagedOAuth) { + throw new Error( + `Plugin "${provider.manifest.name}" manifest.oauth without oauth-bearer credentials requires egress credential hooks.`, + ); + } + continue; + } + + if (!hasGrantHook || !hasIssueHook) { + throw new Error( + `Plugin "${provider.manifest.name}" egress credential hooks must include both grantForEgress and issueCredential.`, + ); + } + if (hasGenericCredentials) { + throw new Error( + `Plugin "${provider.manifest.name}" egress credential hooks must use manifest.domains instead of generic credentials or apiHeaders.`, + ); + } + if (!hasDomains) { + throw new Error( + `Plugin "${provider.manifest.name}" egress credential hooks require manifest.domains to list sandbox egress hosts.`, + ); + } + } +} diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 362640e42..d6bc11a9b 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -22,8 +22,15 @@ import { type JuniorPluginSet, } from "@/plugins"; import { normalizeLocalConversationId } from "@/chat/local/conversation"; -import { setPlugins } from "@/chat/plugins/agent-hooks"; -import { setPluginCatalogConfig } from "@/chat/plugins/registry"; +import { setPlugins, validatePlugins } from "@/chat/plugins/agent-hooks"; +import { + getPluginCatalogSignature, + setPluginCatalogConfig, +} from "@/chat/plugins/registry"; +import { + validatePluginEgressCredentialHooks, + validatePluginRegistrations, +} from "@/chat/plugins/validation"; import type { LocalAgentReply } from "@/chat/local/runner"; export const CHAT_USAGE = "usage: junior chat\n junior chat -p "; @@ -150,15 +157,27 @@ async function loadLocalPluginSet(): Promise { async function configureLocalChatPlugins(): Promise { const pluginSet = await loadLocalPluginSet(); const plugins = pluginHookRegistrationsFromPluginSet(pluginSet); + const pluginConfig = pluginSet + ? pluginCatalogConfigFromPluginSet(pluginSet) + : pluginCatalogConfigFromEnv(); + const shouldValidatePluginCatalog = + Boolean(pluginConfig) || Boolean(pluginSet?.registrations.length); const { validatePluginDatabaseRequirements } = await import("@/chat/plugins/db"); - validatePluginDatabaseRequirements(plugins); - setPluginCatalogConfig( - pluginSet - ? pluginCatalogConfigFromPluginSet(pluginSet) - : pluginCatalogConfigFromEnv(), - ); - setPlugins(plugins); + validatePlugins(plugins); + const previousPluginCatalogConfig = setPluginCatalogConfig(pluginConfig); + try { + if (shouldValidatePluginCatalog) { + getPluginCatalogSignature(); + validatePluginRegistrations(pluginSet?.registrations ?? []); + validatePluginEgressCredentialHooks(pluginSet?.registrations ?? []); + } + validatePluginDatabaseRequirements(plugins); + setPlugins(plugins); + } catch (error) { + setPluginCatalogConfig(previousPluginCatalogConfig); + throw error; + } } function parseChatArgs(argv: string[]): ChatCommandOptions | undefined { diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index b842a85be..0e7f8461e 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -9,7 +9,15 @@ import { import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; const originalEnv = { ...process.env }; const originalCwd = process.cwd(); @@ -20,6 +28,7 @@ const examplePluginsModule = path.join(exampleRoot, "plugins.ts"); const exampleDashboardConfig = path.join(exampleRoot, "dashboard.ts"); const examplePackageJson = path.join(exampleRoot, "package.json"); const exampleRequire = createRequire(exampleEntry); +const exampleDatabaseUrl = "postgres://configured.example.test/neon"; const vercelEnvNames = [ "VERCEL", "VERCEL_ENV", @@ -107,6 +116,10 @@ describe.sequential("example build discovery integration", () => { buildJuniorPackage(); }, 60_000); + beforeEach(() => { + process.env.JUNIOR_DATABASE_URL ??= exampleDatabaseUrl; + }); + afterEach(() => { process.chdir(originalCwd); process.env = { ...originalEnv }; diff --git a/packages/junior/tests/unit/cli/chat-cli.test.ts b/packages/junior/tests/unit/cli/chat-cli.test.ts index 4aec96a12..058f7bb99 100644 --- a/packages/junior/tests/unit/cli/chat-cli.test.ts +++ b/packages/junior/tests/unit/cli/chat-cli.test.ts @@ -160,15 +160,56 @@ describe("chat cli", () => { } }); + it("fails local chat for unresolved plugin packages", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); + writeFileSync( + path.join(tempDir, "plugins.mjs"), + `export const plugins = { + packageNames: ["@sentry/junior-missing-plugin"], + registrations: [], +}; +`, + ); + process.chdir(tempDir); + + try { + const io = { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }; + + expect(await runChat(["-p", "hello"], io)).toBe(1); + expect(io.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Plugin package "@sentry/junior-missing-plugin" was configured but could not be resolved', + ), + ); + expect(runner.runLocalAgentTurn).not.toHaveBeenCalled(); + } finally { + process.chdir(ORIGINAL_CWD); + rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("loads TypeScript local plugin modules before prompt local chat", async () => { const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); writeFileSync( path.join(tempDir, "plugins.ts"), - `const packageNames: string[] = ["@sentry/test-plugin"]; + `const packageNames: string[] = []; export const plugins = { packageNames, - registrations: [], + registrations: [ + { + manifest: { + name: "typescript-plugin", + displayName: "TypeScript Plugin", + description: "TypeScript local plugin module test", + }, + }, + ], }; `, ); diff --git a/packages/junior/vitest.config.ts b/packages/junior/vitest.config.ts index 101451832..01e709dff 100644 --- a/packages/junior/vitest.config.ts +++ b/packages/junior/vitest.config.ts @@ -23,7 +23,6 @@ for (const envRoot of [workspaceRoot, packageRoot]) { } process.env.JUNIOR_SECRET = "junior-test-secret"; -process.env.JUNIOR_DATABASE_URL = "postgres://user:pass@example.test/neon"; process.env.JUNIOR_STATE_ADAPTER = "memory"; process.env.JUNIOR_STATE_KEY_PREFIX ??= `junior:test:${process.pid}`;