diff --git a/.craft.yml b/.craft.yml index cf0580a3..84d213db 100644 --- a/.craft.yml +++ b/.craft.yml @@ -5,6 +5,9 @@ targets: - name: npm id: "@sentry/junior" includeNames: /^sentry-junior-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-plugin-api" + includeNames: /^sentry-junior-plugin-api-\d.*\.tgz$/ - name: npm id: "@sentry/junior-agent-browser" includeNames: /^sentry-junior-agent-browser-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71645aa0..f8cf651e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: run: | mkdir -p artifacts pnpm --filter @sentry/junior pack --pack-destination artifacts + pnpm --filter @sentry/junior-plugin-api pack --pack-destination artifacts pnpm --filter @sentry/junior-agent-browser pack --pack-destination artifacts pnpm --filter @sentry/junior-datadog pack --pack-destination artifacts pnpm --filter @sentry/junior-github pack --pack-destination artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e89c3df5..113baaa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,7 @@ pnpm build:pkg This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior` +- `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-datadog` - `@sentry/junior-github` diff --git a/README.md b/README.md index a749a3ae..0bad762c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Start here: | Package | Purpose | | ------------------------------ | ---------------------------------------------------------------------------- | | `@sentry/junior` | Core Slack bot runtime | +| `@sentry/junior-plugin-api` | Lightweight trusted plugin API types and helpers | | `@sentry/junior-agent-browser` | Agent Browser plugin package for browser automation | | `@sentry/junior-datadog` | Datadog plugin package for observability workflows through Datadog's Pup CLI | | `@sentry/junior-github` | GitHub plugin package for issue workflows | diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index f00db112..d94b4ad1 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -12,6 +12,7 @@ related: Junior uses lockstep package releases for: - `@sentry/junior` +- `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-datadog` - `@sentry/junior-github` 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 e386cb10..9e7dc4b4 100644 --- a/packages/docs/src/content/docs/extend/build-a-plugin.md +++ b/packages/docs/src/content/docs/extend/build-a-plugin.md @@ -38,6 +38,26 @@ 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" + } +} +``` + ## Minimal manifest A plugin can be manifest-only: @@ -116,6 +136,63 @@ export default defineConfig({ Do not use the removed `pluginPackages` option. `junior check` rejects it. +## 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`. + +Export a factory from the plugin package: + +```ts title="index.ts" +import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; + +export function myProviderPlugin() { + return defineJuniorPlugin({ + name: "my-provider", + pluginConfig: { + packages: ["@acme/junior-my-provider"], + }, + hooks: { + async sandboxPrepare(ctx) { + await ctx.sandbox.writeFile({ + path: `${ctx.sandbox.juniorRoot}/my-provider-ready`, + content: "ok\n", + }); + }, + beforeToolExecute(ctx) { + if (ctx.tool.name === "bash") { + ctx.env.set("MY_PROVIDER_NON_SECRET_FLAG", "1"); + } + }, + }, + }); +} +``` + +Register the trusted plugin from the app: + +```ts title="server.ts" +import { createApp } from "@sentry/junior"; +import { myProviderPlugin } from "@acme/junior-my-provider"; + +const app = await createApp({ + plugins: [myProviderPlugin()], +}); + +export default app; +``` + +`pluginConfig.packages` should include the package that contains `plugin.yaml` +so the trusted registration also loads the declarative provider metadata. Any +packages declared through `juniorNitro({ plugins })` continue to load; trusted +plugin package config is merged with the build-time plugin catalog. + +Use `ctx.decision.replaceInput(...)` only with object-shaped tool input. Junior +rejects non-object replacements before the tool runs. + ## Validate Run validation before deploy: diff --git a/packages/docs/src/content/docs/extend/github-plugin.md b/packages/docs/src/content/docs/extend/github-plugin.md index 18ec4746..7a6e1f51 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -21,7 +21,9 @@ pnpm add @sentry/junior @sentry/junior-github ## Runtime setup -List the plugin in `juniorNitro({ plugins: { packages: [...] } })`: +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({ @@ -31,6 +33,25 @@ juniorNitro({ }); ``` +Register the trusted GitHub plugin in `createApp()` so Junior can enforce Git +commit attribution at runtime: + +```ts title="server.ts" +import { createApp } 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; +``` + ## Configure environment variables Set these values in the host environment: diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index 9f3a71d1..6ccbafb6 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -107,6 +107,36 @@ juniorNitro({ 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`. +## 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 +GitHub plugin registers runtime code that 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"; + +const app = await createApp({ + plugins: [ + githubPlugin({ + botNameEnv: "GITHUB_APP_BOT_NAME", + botEmailEnv: "GITHUB_APP_BOT_EMAIL", + }), + ], +}); + +export default app; +``` + +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. + ## Local skills vs plugin skills Junior discovers both: @@ -297,7 +327,7 @@ 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()` — no need to declare it twice. +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-github`, must also be registered from app code. ## Validate extensions diff --git a/packages/docs/src/content/docs/reference/api.md b/packages/docs/src/content/docs/reference/api.md index 466c07ec..b7be6c8c 100644 --- a/packages/docs/src/content/docs/reference/api.md +++ b/packages/docs/src/content/docs/reference/api.md @@ -25,7 +25,9 @@ The API reference is generated from public package entry points. 1. Read [Route & Handler Surface](/reference/handler-surface/) first. 2. Read `createApp` options to understand runtime route wiring. 3. Read `juniorNitro` options before changing plugin package bundling. -4. Read instrumentation exports for telemetry setup. +4. For trusted plugin hooks, use `@sentry/junior-plugin-api` from a plugin + package and register the returned `JuniorPlugin` with `createApp()`. +5. Read instrumentation exports for telemetry setup. ## Next step 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 f6b5b700..d2a3b824 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:118](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L118) +Defined in: [app.ts:175](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L175) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index 725c28b3..0520184e 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:25](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L25) +Defined in: [app.ts:30](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L30) ## Properties @@ -13,7 +13,7 @@ Defined in: [app.ts:25](https://github.com/getsentry/junior/blob/main/packages/j > `optional` **configDefaults?**: `Record`\<`string`, `unknown`\> -Defined in: [app.ts:27](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L27) +Defined in: [app.ts:32](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L32) Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. @@ -21,11 +21,15 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p ### plugins? -> `optional` **plugins?**: `PluginConfig` +> `optional` **plugins?**: `PluginConfig` \| `JuniorPlugin`[] + +Defined in: [app.ts:40](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L40) -Defined in: [app.ts:29](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L29) +Plugin packages/overrides, or trusted plugin instances loaded by this app. -Plugin packages and manifest overrides loaded by this app instance. +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()`. --- @@ -33,4 +37,4 @@ Plugin packages and manifest overrides loaded by this app instance. > `optional` **waitUntil?**: `WaitUntilFn` -Defined in: [app.ts:30](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L30) +Defined in: [app.ts:41](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L41) 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 1b92d4f5..1f793799 100644 --- a/packages/docs/src/content/docs/start-here/existing-app.md +++ b/packages/docs/src/content/docs/start-here/existing-app.md @@ -44,7 +44,7 @@ export default defineConfig({ modules: [ juniorNitro({ plugins: { - packages: ["@sentry/junior-github"], + packages: ["@sentry/junior-sentry"], }, }), ], @@ -56,6 +56,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`, `/api/info`, and `/health`. Do not split those routes across independent runtime instances. +Some packages also export trusted runtime hooks. Register those in `createApp()`; +do not rely on `juniorNitro()` alone. For example, see +[GitHub Plugin](/extend/github-plugin/) for the `githubPlugin()` app-code setup. + ## Add app files Junior expects app context and local extension files under `app/`: diff --git a/packages/docs/src/content/docs/start-here/quickstart.md b/packages/docs/src/content/docs/start-here/quickstart.md index e4ea1784..be00aaf2 100644 --- a/packages/docs/src/content/docs/start-here/quickstart.md +++ b/packages/docs/src/content/docs/start-here/quickstart.md @@ -137,6 +137,11 @@ Run the app check after changing plugins or skills: pnpm check ``` +Plugins with trusted runtime hooks need one more app-code registration step. +For example, `@sentry/junior-github` must be registered with `githubPlugin()` +inside `createApp()` to enforce Git commit attribution. See +[GitHub Plugin](/extend/github-plugin/) for that setup. + ## Verify plugin content When enabled plugins declare sandbox runtime dependencies, the scaffolded build runs snapshot warmup: diff --git a/packages/junior-github/README.md b/packages/junior-github/README.md index 826b29af..fdb57634 100644 --- a/packages/junior-github/README.md +++ b/packages/junior-github/README.md @@ -8,4 +8,23 @@ Install it alongside `@sentry/junior`: pnpm add @sentry/junior @sentry/junior-github ``` +Register the trusted plugin from app code: + +```ts +import { createApp } 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", + }), + ], +}); +``` + +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 new file mode 100644 index 00000000..a5d1133d --- /dev/null +++ b/packages/junior-github/index.d.ts @@ -0,0 +1,9 @@ +import type { JuniorPlugin } 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; diff --git a/packages/junior-github/index.js b/packages/junior-github/index.js new file mode 100644 index 00000000..7b246af4 --- /dev/null +++ b/packages/junior-github/index.js @@ -0,0 +1,150 @@ +import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; + +function readEnv(name) { + const value = process.env[name]; + return typeof value === "string" && value ? value : undefined; +} + +function cleanIdentityPart(value) { + return String(value ?? "") + .replaceAll("\n", " ") + .replaceAll("\r", " ") + .replace(/[<>]/g, "") + .trim(); +} + +function requesterName(requester) { + return ( + cleanIdentityPart(requester?.fullName) || + cleanIdentityPart(requester?.userName) || + cleanIdentityPart(requester?.userId) || + undefined + ); +} + +function requesterEmail(requester) { + const email = cleanIdentityPart(requester?.email); + return email && !/\s/.test(email) ? email : "noreply"; +} + +function isGitCommitCommand(command) { + return /(?:^|[\s;|&])git(?:\s+(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--namespace(?:=\S+|\s+\S+)))*\s+commit(?:\s|$)/.test( + command, + ); +} + +function prepareCommitMsgHook() { + return `#!/usr/bin/env bash +set -eu + +message_file="\${1:-}" +if [ -z "$message_file" ]; then + exit 1 +fi + +if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then + echo "Junior GitHub plugin internal error: bot commit attribution was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2 + exit 1 +fi + +if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then + echo "Junior GitHub plugin internal error: Git author was not set to the configured bot identity. Do not override Git author manually; report this configuration error." >&2 + exit 1 +fi + +if [ -z "\${JUNIOR_GIT_COAUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_COAUTHOR_EMAIL:-}" ]; then + echo "Junior GitHub plugin internal error: requester coauthor identity was not injected by the host runtime. Do not set coauthor env vars manually; report this configuration error." >&2 + exit 1 +fi + +trailer="Co-authored-by: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>" +if grep -Fqx "$trailer" "$message_file"; then + exit 0 +fi + +printf '\\n%s\\n' "$trailer" >> "$message_file" +`; +} + +async function configureGit(ctx, key, value) { + const result = await ctx.sandbox.run({ + cmd: "git", + args: ["config", "--global", key, value], + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to configure git ${key}: ${result.stderr || result.stdout}`, + ); + } +} + +/** Register trusted GitHub runtime hooks for commit attribution and package loading. */ +export function githubPlugin(options = {}) { + const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME"; + const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL"; + + return defineJuniorPlugin({ + name: "github", + pluginConfig: { + packages: ["@sentry/junior-github"], + }, + hooks: { + async sandboxPrepare(ctx) { + const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`; + await ctx.sandbox.writeFile({ + path: `${hooksPath}/prepare-commit-msg`, + mode: 0o755, + content: prepareCommitMsgHook(), + }); + await Promise.all([ + configureGit(ctx, "core.hooksPath", hooksPath), + configureGit(ctx, "commit.gpgsign", "false"), + configureGit(ctx, "credential.helper", ""), + configureGit(ctx, "http.emptyAuth", "true"), + ]); + }, + beforeToolExecute(ctx) { + if (ctx.tool.name !== "bash") { + return; + } + const command = + typeof ctx.tool.input === "object" && + ctx.tool.input && + "command" in ctx.tool.input + ? String(ctx.tool.input.command ?? "") + : ""; + const botName = readEnv(botNameEnv); + const botEmail = readEnv(botEmailEnv); + if ((!botName || !botEmail) && isGitCommitCommand(command)) { + ctx.decision.deny( + `Junior GitHub plugin is misconfigured: host env vars ${botNameEnv} and ${botEmailEnv} are missing. This is an internal deployment configuration error; do not set them in the sandbox.`, + ); + return; + } + if (!botName || !botEmail) { + return; + } + const coauthorName = requesterName(ctx.requester); + if (!coauthorName && isGitCommitCommand(command)) { + ctx.decision.deny( + "Junior GitHub plugin could not determine requester identity for commit attribution. This is an internal request-context error; do not set coauthor env vars manually.", + ); + return; + } + ctx.env.set("GIT_AUTHOR_NAME", botName); + ctx.env.set("GIT_AUTHOR_EMAIL", botEmail); + ctx.env.set("GIT_COMMITTER_NAME", botName); + ctx.env.set("GIT_COMMITTER_EMAIL", botEmail); + ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", botName); + ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", botEmail); + if (coauthorName) { + ctx.env.set("JUNIOR_GIT_COAUTHOR_NAME", coauthorName); + ctx.env.set( + "JUNIOR_GIT_COAUTHOR_EMAIL", + requesterEmail(ctx.requester), + ); + } + }, + }, + }); +} diff --git a/packages/junior-github/package.json b/packages/junior-github/package.json index 39edbf92..d41faf5d 100644 --- a/packages/junior-github/package.json +++ b/packages/junior-github/package.json @@ -6,9 +6,20 @@ "access": "public" }, "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, "files": [ + "index.d.ts", + "index.js", "plugin.yaml", "skills", "SETUP.md" - ] + ], + "dependencies": { + "@sentry/junior-plugin-api": "workspace:*" + } } diff --git a/packages/junior-github/skills/github-code/SKILL.md b/packages/junior-github/skills/github-code/SKILL.md index 1a2be014..edbd619b 100644 --- a/packages/junior-github/skills/github-code/SKILL.md +++ b/packages/junior-github/skills/github-code/SKILL.md @@ -10,10 +10,10 @@ Use `gh` and `git` for repository checkout, source investigation, code changes, ## References -| Need | Load | -| ---- | ---- | -| Command syntax, permissions, config | [references/api-surface.md](references/api-surface.md) | -| Failed commands, auth errors | [references/troubleshooting-workarounds.md](references/troubleshooting-workarounds.md) | +| Need | Load | +| ----------------------------------- | -------------------------------------------------------------------------------------- | +| Command syntax, permissions, config | [references/api-surface.md](references/api-surface.md) | +| Failed commands, auth errors | [references/troubleshooting-workarounds.md](references/troubleshooting-workarounds.md) | ## Core rules @@ -102,17 +102,7 @@ Types: `feat`, `fix`, `ref`, `docs`, `test`, `build`, `ci`, `chore`. Imperative Body only when it helps reviewers understand _why_. -Footer order: `Fixes`/`Refs` lines, then `Co-authored-by` trailers. - -#### On-behalf-of commits - -If the commit is authored by a bot and a human requested the work, add a trailer: - -``` -Co-authored-by: Full Name -``` - -Resolve name and email from evidence — requester context, Slack profile (`slackUserLookup`), GitHub profile, or repo commit history. If email cannot be confirmed, use `Full Name ` and note the gap in the PR body. +Footer order: `Fixes`/`Refs` lines. ### 6. Create or update PR diff --git a/packages/junior-plugin-api/package.json b/packages/junior-plugin-api/package.json new file mode 100644 index 00000000..2b7752b8 --- /dev/null +++ b/packages/junior-plugin-api/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sentry/junior-plugin-api", + "version": "0.53.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tsup.config.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "oxlint": "^1.66.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3" + } +} diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts new file mode 100644 index 00000000..157629de --- /dev/null +++ b/packages/junior-plugin-api/src/index.ts @@ -0,0 +1,79 @@ +export interface AgentPluginRequester { + userId?: string; + userName?: string; + fullName?: string; + email?: string; +} + +export interface AgentPluginMetadata { + name: string; +} + +export interface AgentPluginEnv { + get(key: string): string | undefined; + set(key: string, value: string): void; +} + +export interface AgentPluginDecision { + deny(message: string): void; + replaceInput(input: Record): void; +} + +export interface AgentPluginSandbox { + juniorRoot: string; + root: string; + readFile(path: string): Promise; + run(input: { + args?: string[]; + cmd: string; + cwd?: string; + env?: Record; + sudo?: boolean; + }): Promise<{ + exitCode: number; + stderr: string; + stdout: string; + }>; + writeFile(input: { + content: string | Uint8Array; + mode?: number; + path: string; + }): Promise; +} + +export interface SandboxPrepareHookContext { + plugin: AgentPluginMetadata; + requester?: AgentPluginRequester; + sandbox: AgentPluginSandbox; +} + +export interface BeforeToolExecuteHookContext { + decision: AgentPluginDecision; + env: AgentPluginEnv; + plugin: AgentPluginMetadata; + requester?: AgentPluginRequester; + tool: { + input: Record; + name: string; + }; +} + +export interface AgentPluginHooks { + sandboxPrepare?(ctx: SandboxPrepareHookContext): Promise | void; + beforeToolExecute?(ctx: BeforeToolExecuteHookContext): Promise | void; +} + +export interface JuniorPluginConfig { + packages?: string[]; +} + +export interface JuniorPlugin { + hooks?: AgentPluginHooks; + name: string; + pluginConfig?: JuniorPluginConfig; +} + +/** Define a trusted Junior plugin with optional package config and agent hooks. */ +export function defineJuniorPlugin(plugin: JuniorPlugin): JuniorPlugin { + return plugin; +} diff --git a/packages/junior-plugin-api/tsconfig.build.json b/packages/junior-plugin-api/tsconfig.build.json new file mode 100644 index 00000000..b398e67a --- /dev/null +++ b/packages/junior-plugin-api/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "incremental": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/junior-plugin-api/tsconfig.json b/packages/junior-plugin-api/tsconfig.json new file mode 100644 index 00000000..6b596791 --- /dev/null +++ b/packages/junior-plugin-api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-plugin-api/tsup.config.ts b/packages/junior-plugin-api/tsup.config.ts new file mode 100644 index 00000000..196ab137 --- /dev/null +++ b/packages/junior-plugin-api/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: "esm", + tsconfig: "tsconfig.build.json", + dts: false, + outDir: "dist", + clean: true, +}); diff --git a/packages/junior/README.md b/packages/junior/README.md index 24e49149..f6892c08 100644 --- a/packages/junior/README.md +++ b/packages/junior/README.md @@ -25,7 +25,7 @@ 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. +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: [...] })`. ## Full docs diff --git a/packages/junior/package.json b/packages/junior/package.json index 230b9ef3..63358b46 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -34,6 +34,7 @@ "skills:check": "node scripts/check-skills.mjs" }, "dependencies": { + "@sentry/junior-plugin-api": "workspace:*", "@ai-sdk/gateway": "^3.0.119", "@chat-adapter/slack": "4.29.0", "@chat-adapter/state-memory": "4.29.0", diff --git a/packages/junior/skills/junior/references/packaging.md b/packages/junior/skills/junior/references/packaging.md index 0d5faf06..eb3ad11c 100644 --- a/packages/junior/skills/junior/references/packaging.md +++ b/packages/junior/skills/junior/references/packaging.md @@ -53,6 +53,21 @@ 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: + +```ts +import { createApp } from "@sentry/junior"; +import { myProviderPlugin } from "@acme/junior-my-provider"; + +const app = await createApp({ + plugins: [myProviderPlugin()], +}); +``` + +The trusted plugin's `pluginConfig.packages` should include the package that +contains `plugin.yaml`. Nitro still owns build-time package copying. + ## Monorepo package checklist When adding a new package under this repository's `packages/` directory: diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 100f8d5a..ea0c0fa9 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -8,7 +8,12 @@ import { getPluginCatalogSignature, setPluginConfig, } from "@/chat/plugins/registry"; +import { + setAgentPlugins, + validateAgentPlugins, +} from "@/chat/plugins/agent-hooks"; import type { PluginConfig } from "@/chat/plugins/types"; +import type { JuniorPlugin } from "@sentry/junior-plugin-api"; import { GET as diagnosticsGET } from "@/handlers/diagnostics"; import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard"; import { GET as healthGET } from "@/handlers/health"; @@ -25,8 +30,14 @@ import type { WaitUntilFn } from "@/handlers/types"; export interface JuniorAppOptions { /** Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. */ configDefaults?: Record; - /** Plugin packages and manifest overrides loaded by this app instance. */ - plugins?: PluginConfig; + /** + * 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[]; waitUntil?: WaitUntilFn; } @@ -48,7 +59,7 @@ async function defaultWaitUntil(): Promise { } /** Resolve plugin configuration from the virtual module injected by juniorNitro(). */ -async function resolveBuildPluginConfig(): Promise { +async function resolveVirtualPluginConfig(): Promise { try { const mod: { plugins?: PluginConfig } = await import("#junior/config"); return mod.plugins; @@ -56,14 +67,24 @@ async function resolveBuildPluginConfig(): Promise { if (!isMissingVirtualConfig(error)) { throw error; } - const packages = readEnvPluginPackages(); - if (packages) { - return { packages }; - } return undefined; } } +/** Resolve plugin configuration from the virtual module, falling back to env. */ +async function resolveBuildPluginConfig(): Promise { + const virtualConfig = await resolveVirtualPluginConfig(); + if (virtualConfig) { + return virtualConfig; + } + + const packages = readEnvPluginPackages(); + if (packages) { + return { packages }; + } + return undefined; +} + function isMissingVirtualConfig(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -114,13 +135,62 @@ function hasConfiguredPluginCatalog(config: PluginConfig | undefined): boolean { ); } +function isJuniorPluginArray( + plugins: JuniorAppOptions["plugins"], +): plugins is JuniorPlugin[] { + return Array.isArray(plugins); +} + +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 pluginConfigFromAgentPlugins( + plugins: JuniorPlugin[], +): PluginConfig | undefined { + const packages = [ + ...new Set( + plugins.flatMap((plugin) => plugin.pluginConfig?.packages ?? []), + ), + ]; + return packages.length ? { packages } : undefined; +} + /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { - const pluginConfig = options?.plugins ?? (await resolveBuildPluginConfig()); + const configuredPlugins = options?.plugins; + const agentPlugins = isJuniorPluginArray(configuredPlugins) + ? configuredPlugins + : []; + const pluginConfig = isJuniorPluginArray(configuredPlugins) + ? mergePluginConfig( + await resolveVirtualPluginConfig(), + pluginConfigFromAgentPlugins(configuredPlugins), + ) + : (configuredPlugins ?? (await resolveBuildPluginConfig())); + validateAgentPlugins(agentPlugins); const shouldValidatePluginCatalog = hasConfiguredPluginCatalog(pluginConfig) || Boolean(Object.keys(options?.configDefaults ?? {}).length); const previousPluginConfig = setPluginConfig(pluginConfig); + const previousAgentPlugins = setAgentPlugins(agentPlugins); const previousConfigDefaults = getConfigDefaults(); try { setConfigDefaults(options?.configDefaults); @@ -129,6 +199,7 @@ export async function createApp(options?: JuniorAppOptions): Promise { } } catch (error) { setPluginConfig(previousPluginConfig); + setAgentPlugins(previousAgentPlugins); setConfigDefaults(previousConfigDefaults); throw error; } diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts new file mode 100644 index 00000000..ee16465c --- /dev/null +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -0,0 +1,206 @@ +import type { + AgentPluginRequester, + AgentPluginSandbox, + JuniorPlugin, +} from "@sentry/junior-plugin-api"; +import { logInfo } from "@/chat/logging"; +import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; +import type { + SandboxCommandInput, + SandboxInstance, +} from "@/chat/sandbox/workspace"; + +/** Signal that a trusted plugin intentionally denied a tool execution. */ +export class AgentPluginHookDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentPluginHookDeniedError"; + } +} + +export interface ToolHookInput { + input: Record; + name: string; +} + +export interface ToolHookResult { + env: Record; + input: Record; +} + +export interface AgentPluginHookRunner { + beforeToolExecute(input: ToolHookInput): Promise; + prepareSandbox(sandbox: SandboxInstance): Promise; +} + +let agentPlugins: JuniorPlugin[] = []; +const AGENT_PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; + +/** Validate trusted plugin identity before it can affect process-wide hooks. */ +export function validateAgentPlugins(plugins: JuniorPlugin[]): void { + const seen = new Set(); + for (const plugin of plugins) { + if (!AGENT_PLUGIN_NAME_RE.test(plugin.name)) { + throw new Error( + `Trusted plugin name "${plugin.name}" must be a lowercase plugin identifier`, + ); + } + if (seen.has(plugin.name)) { + throw new Error(`Duplicate trusted plugin name "${plugin.name}"`); + } + seen.add(plugin.name); + } +} + +/** Replace trusted agent plugins and return the previous list for rollback. */ +export function setAgentPlugins(plugins: JuniorPlugin[]): JuniorPlugin[] { + validateAgentPlugins(plugins); + const previous = agentPlugins; + agentPlugins = [...plugins].sort((left, right) => + left.name.localeCompare(right.name), + ); + return previous; +} + +/** Return the current trusted agent plugins without exposing mutable state. */ +export function getAgentPlugins(): JuniorPlugin[] { + return [...agentPlugins]; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeEnv(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const env: Record = {}; + for (const [key, rawValue] of Object.entries(value)) { + if (typeof rawValue === "string") { + env[key] = rawValue; + } + } + return env; +} + +function createSandboxCapability(sandbox: SandboxInstance): AgentPluginSandbox { + return { + root: SANDBOX_WORKSPACE_ROOT, + juniorRoot: `${SANDBOX_WORKSPACE_ROOT}/.junior`, + async readFile(filePath) { + return (await sandbox.readFileToBuffer({ path: filePath })) ?? null; + }, + async run(input: SandboxCommandInput) { + const result = await sandbox.runCommand(input); + const [stdout, stderr] = await Promise.all([ + result.stdout(), + result.stderr(), + ]); + return { + exitCode: result.exitCode, + stdout, + stderr, + }; + }, + async writeFile(input) { + await sandbox.writeFiles([ + { + path: input.path, + content: input.content, + ...(input.mode !== undefined ? { mode: input.mode } : {}), + }, + ]); + }, + }; +} + +/** Create one runner over trusted agent plugins registered by the app. */ +export function createAgentPluginHookRunner( + input: { + requester?: AgentPluginRequester; + } = {}, +): AgentPluginHookRunner { + const loaded = getAgentPlugins(); + + return { + async prepareSandbox(sandbox) { + const sandboxCapability = createSandboxCapability(sandbox); + for (const plugin of loaded) { + const hook = plugin.hooks?.sandboxPrepare; + if (!hook) { + continue; + } + logInfo( + "agent_plugin_hook_sandbox_prepare", + {}, + { "app.plugin.name": plugin.name }, + "Running agent plugin sandbox prepare hook", + ); + await hook({ + plugin: { name: plugin.name }, + requester: input.requester, + sandbox: sandboxCapability, + }); + } + }, + async beforeToolExecute(tool) { + let nextInput = { ...tool.input }; + const env = normalizeEnv(nextInput.env); + + for (const plugin of loaded) { + const hook = plugin.hooks?.beforeToolExecute; + if (!hook) { + continue; + } + let replacement: Record | undefined; + let denied: string | undefined; + await hook({ + plugin: { name: plugin.name }, + requester: input.requester, + tool: { + name: tool.name, + input: nextInput, + }, + env: { + get(key) { + return env[key]; + }, + set(key, value) { + env[key] = value; + }, + }, + decision: { + deny(message) { + denied = message; + }, + replaceInput(input) { + replacement = input; + }, + }, + }); + + if (denied) { + throw new AgentPluginHookDeniedError(denied); + } + if (replacement !== undefined) { + if (!isRecord(replacement)) { + throw new Error( + `Plugin "${plugin.name}" replaced tool input with a non-object value`, + ); + } + nextInput = { ...replacement }; + Object.assign(env, normalizeEnv(nextInput.env)); + } + } + + return { + input: { + ...nextInput, + ...(Object.keys(env).length > 0 ? { env } : {}), + }, + env, + }; + }, + }; +} diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index effa896a..cf8cd7b2 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -31,6 +31,7 @@ import { getPluginMcpProviders, getPluginProviders, } from "@/chat/plugins/registry"; +import { createAgentPluginHookRunner } from "@/chat/plugins/agent-hooks"; import { McpToolManager } from "@/chat/mcp/tool-manager"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { ConversationPendingAuthState } from "@/chat/state/conversation"; @@ -118,6 +119,7 @@ export interface ReplyRequestContext { userId?: string; userName?: string; fullName?: string; + email?: string; }; correlation?: { conversationId?: string; @@ -448,6 +450,9 @@ export async function generateAssistantReply( // ── Sandbox ────────────────────────────────────────────────────── const requesterId = context.requester?.userId; const userTokenStore = createUserTokenStore(); + const agentPluginHooks = createAgentPluginHookRunner({ + requester: context.requester, + }); sandboxExecutor = createSandboxExecutor({ sandboxId: context.sandbox?.sandboxId, sandboxDependencyProfileHash: @@ -458,6 +463,7 @@ export async function generateAssistantReply( requesterId, } : undefined, + agentHooks: agentPluginHooks, onSandboxAcquired: async (sandbox) => { lastKnownSandboxId = sandbox.sandboxId; lastKnownSandboxDependencyProfileHash = @@ -831,6 +837,7 @@ export async function generateAssistantReply( sandboxExecutor, pluginAuth, onToolCall, + agentPluginHooks, ); advisorTools = createAgentTools( createAdvisorToolDefinitions(tools), @@ -840,6 +847,7 @@ export async function generateAssistantReply( sandboxExecutor, pluginAuth, onToolCall, + agentPluginHooks, ); // Keep Pi's native tool schema static for the whole turn. Ideally this // would use provider-native tool loading/search APIs, but Pi's generic diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 54ab5e43..a614bc09 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -510,6 +510,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { userId: message.author.userId, userName: message.author.userName ?? fallbackIdentity?.userName, fullName: message.author.fullName ?? fallbackIdentity?.fullName, + email: fallbackIdentity?.email, }, conversationContext: preparedState.routingContext ?? preparedState.conversationContext, diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index b9cdf6ad..d3eec0a1 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -14,6 +14,7 @@ import { createSandboxEgressRequesterToken } from "@/chat/sandbox/egress-session import { throwSandboxOperationError } from "@/chat/sandbox/errors"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; import { createSandboxSessionManager } from "@/chat/sandbox/session"; +import type { AgentPluginHookRunner } from "@/chat/plugins/agent-hooks"; import { isHostFileMissingError, resolveHostDataPath, @@ -108,6 +109,7 @@ export function createSandboxExecutor(options?: { credentialEgress?: { requesterId: string; }; + agentHooks?: AgentPluginHookRunner; onSandboxAcquired?: (sandbox: SandboxAcquiredState) => void | Promise; runBashCustomCommand?: ( command: string, @@ -159,6 +161,9 @@ export function createSandboxExecutor(options?: { requesterToken: sandboxEgressRequesterTokenFor(egressId), }) : undefined, + onSandboxPrepare: async (sandbox) => { + await options?.agentHooks?.prepareSandbox(sandbox); + }, onSandboxAcquired: async (sandbox) => { await options?.onSandboxAcquired?.(sandbox); }, diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index f2a15dcd..21f51fab 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -160,6 +160,7 @@ export function createSandboxSessionManager(options?: { traceContext?: LogContext; commandEnv?: () => Promise>; createNetworkPolicy?: (egressId: string) => NetworkPolicy | undefined; + onSandboxPrepare?: (sandbox: SandboxInstance) => void | Promise; onSandboxAcquired?: (sandbox: { sandboxId: string; sandboxDependencyProfileHash?: string; @@ -171,6 +172,7 @@ export function createSandboxSessionManager(options?: { let availableReferenceFiles: string[] = []; let toolExecutors: SandboxToolExecutors | undefined; let appliedNetworkPolicyKey: string | undefined; + let preparedSandboxId: string | undefined; const timeoutMs = options?.timeoutMs ?? 1000 * 60 * 30; const traceContext = options?.traceContext ?? {}; @@ -191,6 +193,7 @@ export function createSandboxSessionManager(options?: { sandboxIdHint = undefined; toolExecutors = undefined; appliedNetworkPolicyKey = undefined; + preparedSandboxId = undefined; }; const createSandboxName = (): string => @@ -234,6 +237,17 @@ export function createSandboxSessionManager(options?: { }); }; + const prepareSandbox = async ( + targetSandbox: SandboxInstance, + ): Promise => { + if (preparedSandboxId === targetSandbox.sandboxId) { + return; + } + await syncSkills(targetSandbox); + await options?.onSandboxPrepare?.(targetSandbox); + preparedSandboxId = targetSandbox.sandboxId; + }; + const refreshNetworkPolicy = async ( targetSandbox: SandboxInstance, ): Promise => { @@ -445,7 +459,7 @@ export function createSandboxSessionManager(options?: { try { await refreshNetworkPolicy(createdSandbox); - await syncSkills(createdSandbox); + await prepareSandbox(createdSandbox); } catch (error) { return failSetup(error); } @@ -487,6 +501,7 @@ export function createSandboxSessionManager(options?: { try { await ensureSandboxReachable(cachedSandbox, "memory"); await refreshNetworkPolicy(cachedSandbox); + await prepareSandbox(cachedSandbox); return cachedSandbox; } catch (error) { if (isSandboxUnavailableError(error)) { @@ -526,7 +541,7 @@ export function createSandboxSessionManager(options?: { try { await refreshNetworkPolicy(hintedSandbox); - await syncSkills(hintedSandbox); + await prepareSandbox(hintedSandbox); return await rememberSandbox(hintedSandbox); } catch (error) { if (isSandboxUnavailableError(error)) { diff --git a/packages/junior/src/chat/slack/user.ts b/packages/junior/src/chat/slack/user.ts index 2c0ef75a..14e7fbea 100644 --- a/packages/junior/src/chat/slack/user.ts +++ b/packages/junior/src/chat/slack/user.ts @@ -4,6 +4,7 @@ import { logWarn } from "@/chat/logging"; interface SlackUserLookupResult { userName?: string; fullName?: string; + email?: string; } const USER_CACHE_TTL_MS = 5 * 60 * 1000; @@ -78,6 +79,7 @@ export async function lookupSlackUser( profile?: { display_name?: string; real_name?: string; + email?: string; }; }; }; @@ -96,6 +98,7 @@ export async function lookupSlackUser( const result: SlackUserLookupResult = { userName, fullName, + email: payload.user.profile?.email?.trim() || undefined, }; writeToCache(userId, result); return result; diff --git a/packages/junior/src/chat/tools/agent-tools.ts b/packages/junior/src/chat/tools/agent-tools.ts index 761c05e2..608b86df 100644 --- a/packages/junior/src/chat/tools/agent-tools.ts +++ b/packages/junior/src/chat/tools/agent-tools.ts @@ -13,6 +13,7 @@ import type { ToolDefinition } from "@/chat/tools/definition"; import { buildSandboxInput } from "@/chat/tools/execution/build-sandbox-input"; import { normalizeToolResult } from "@/chat/tools/execution/normalize-result"; import { handleToolExecutionError } from "@/chat/tools/execution/tool-error-handler"; +import type { AgentPluginHookRunner } from "@/chat/plugins/agent-hooks"; /** Wrap tool definitions into Pi Agent tool objects with logging, validation, and sandbox execution. */ export function createAgentTools( @@ -23,6 +24,7 @@ export function createAgentTools( sandboxExecutor?: SandboxExecutor, pluginAuthOrchestration?: PluginAuthOrchestration, onToolCall?: (toolName: string, params: Record) => void, + agentHooks?: AgentPluginHookRunner, ): AgentTool[] { const shouldTrace = shouldEmitDevAgentTrace(); return Object.entries(tools).map(([toolName, toolDef]) => ({ @@ -50,7 +52,6 @@ export function createAgentTools( spanContext, async () => { const parsed = params as Record; - onToolCall?.(toolName, parsed); try { if (typeof toolDef.execute !== "function") { @@ -68,19 +69,27 @@ export function createAgentTools( }; } + const beforeTool = agentHooks + ? await agentHooks.beforeToolExecute({ + name: toolName, + input: parsed, + }) + : { input: parsed, env: {} }; + const toolInput = beforeTool.input; + onToolCall?.(toolName, toolInput); const bashCommand = - toolName === "bash" && typeof parsed.command === "string" - ? parsed.command.trim() + toolName === "bash" && typeof toolInput.command === "string" + ? toolInput.command.trim() : ""; - const sandboxInput = buildSandboxInput(toolName, parsed); + const sandboxInput = buildSandboxInput(toolName, toolInput); const isSandbox = Boolean(sandboxExecutor?.canExecute(toolName)); const result = isSandbox ? await sandboxExecutor!.execute({ toolName, input: sandboxInput, }) - : await toolDef.execute(parsed as never, { + : await toolDef.execute(toolInput as never, { experimental_context: sandbox, }); diff --git a/packages/junior/src/chat/tools/execution/build-sandbox-input.ts b/packages/junior/src/chat/tools/execution/build-sandbox-input.ts index bbeebf2e..640e248d 100644 --- a/packages/junior/src/chat/tools/execution/build-sandbox-input.ts +++ b/packages/junior/src/chat/tools/execution/build-sandbox-input.ts @@ -9,6 +9,11 @@ export function buildSandboxInput( if (toolName === "bash") { return { command: String(params.command ?? ""), + ...(params.env && + typeof params.env === "object" && + !Array.isArray(params.env) + ? { env: params.env } + : {}), ...(optionalNumber(params.timeoutMs) ? { timeoutMs: optionalNumber(params.timeoutMs) } : {}), diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index 681af6ee..857b1168 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -1,12 +1,14 @@ 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 { getConfigDefaults, setConfigDefaults, } from "@/chat/configuration/defaults"; +import { getAgentPlugins, setAgentPlugins } from "@/chat/plugins/agent-hooks"; import { getPluginProviders, setPluginConfig } from "@/chat/plugins/registry"; const originalCwd = process.cwd(); @@ -46,6 +48,7 @@ async function writePluginPackage( afterEach(async () => { process.chdir(originalCwd); + setAgentPlugins([]); setPluginConfig(undefined); setConfigDefaults(undefined); if (originalPluginPackages === undefined) { @@ -77,6 +80,17 @@ describe("createApp plugin config", () => { ); }); + it("does not read env plugin packages when trusted plugins are explicit", async () => { + process.env.JUNIOR_PLUGIN_PACKAGES = "not-json"; + + await createApp({ + plugins: [], + }); + + expect(getPluginProviders()).toEqual([]); + expect(getAgentPlugins()).toEqual([]); + }); + it("fails loudly when configured plugin package names are invalid", async () => { await expect( createApp({ @@ -169,4 +183,71 @@ describe("createApp plugin config", () => { ]); expect(getConfigDefaults()).toEqual({ "base.org": "sentry" }); }); + + it("loads trusted plugin instances through createApp", async () => { + const tempRoot = await makeTempDir(); + await writePluginPackage(tempRoot, "@acme/trusted-plugin", "trusted"); + await fs.writeFile( + path.join(tempRoot, "package.json"), + JSON.stringify({ + name: "temp-junior-app", + private: true, + dependencies: { + "@acme/trusted-plugin": "1.0.0", + }, + }), + "utf8", + ); + process.chdir(tempRoot); + + await createApp({ + plugins: [ + defineJuniorPlugin({ + name: "trusted", + pluginConfig: { packages: ["@acme/trusted-plugin"] }, + }), + ], + configDefaults: { "trusted.org": "sentry" }, + }); + + expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "trusted", + ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); + }); + + it("rejects duplicate trusted plugin names before mutating app config", async () => { + await createApp({ + plugins: [], + }); + + await expect( + createApp({ + plugins: [ + defineJuniorPlugin({ name: "dupe" }), + defineJuniorPlugin({ name: "dupe" }), + ], + }), + ).rejects.toThrow('Duplicate trusted plugin name "dupe"'); + + expect(getAgentPlugins()).toEqual([]); + expect(getPluginProviders()).toEqual([]); + }); + + it("rejects invalid trusted plugin names before mutating app config", async () => { + await createApp({ + plugins: [], + }); + + await expect( + createApp({ + plugins: [defineJuniorPlugin({ name: "GitHub" })], + }), + ).rejects.toThrow( + 'Trusted plugin name "GitHub" must be a lowercase plugin identifier', + ); + + expect(getAgentPlugins()).toEqual([]); + expect(getPluginProviders()).toEqual([]); + }); }); diff --git a/packages/junior/tests/unit/misc/sandbox-executor.test.ts b/packages/junior/tests/unit/misc/sandbox-executor.test.ts index e53081b1..3befe930 100644 --- a/packages/junior/tests/unit/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/unit/misc/sandbox-executor.test.ts @@ -313,6 +313,27 @@ describe("createSandboxExecutor", () => { }); }); + it("prepares a cached sandbox only once", async () => { + const freshSandbox = makeSandbox("sbx_fresh"); + const onSandboxPrepare = vi.fn(); + sandboxCreateMock.mockResolvedValue(freshSandbox); + + const manager = createSandboxSessionManager({ + onSandboxPrepare, + }); + manager.configureSkills([]); + + await manager.createSandbox(); + await manager.createSandbox(); + + expect(onSandboxPrepare).toHaveBeenCalledTimes(1); + expect(onSandboxPrepare).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxId: "sbx_fresh", + }), + ); + }); + it("reports acquired sandbox metadata when restoring from a sandbox id hint", async () => { const restoredSandbox = makeSandbox("sbx_restored"); const onSandboxAcquired = vi.fn(); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts new file mode 100644 index 00000000..de1f96d5 --- /dev/null +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -0,0 +1,130 @@ +import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; +import { describe, expect, it } from "vitest"; +import { + createAgentPluginHookRunner, + setAgentPlugins, +} from "@/chat/plugins/agent-hooks"; +import type { SandboxInstance } from "@/chat/sandbox/workspace"; + +function fakeSandbox( + writes: Array<{ content: string | Uint8Array; path: string }>, +): SandboxInstance { + return { + sandboxId: "sandbox-agent-hooks", + sandboxEgressId: "session-agent-hooks", + fs: { + async readFile() { + return ""; + }, + async writeFile() {}, + async readdir() { + return []; + }, + async stat() { + return { isDirectory: () => false }; + }, + }, + async extendTimeout() {}, + async mkDir() {}, + async readFileToBuffer() { + return null; + }, + async runCommand() { + return { + exitCode: 0, + async stdout() { + return ""; + }, + async stderr() { + return ""; + }, + }; + }, + async snapshot() { + return { snapshotId: "snapshot-agent-hooks" }; + }, + async stop() {}, + async update() {}, + async writeFiles(files) { + writes.push( + ...files.map((file) => ({ + path: file.path, + content: file.content, + })), + ); + }, + }; +} + +describe("agent plugin hooks", () => { + it("runs sandbox and tool lifecycle hooks from configured plugins", async () => { + const writes: Array<{ content: string | Uint8Array; path: string }> = []; + const previous = setAgentPlugins([ + defineJuniorPlugin({ + name: "agent-demo", + hooks: { + async sandboxPrepare(ctx) { + await ctx.sandbox.writeFile({ + path: `${ctx.sandbox.juniorRoot}/prepared.txt`, + content: ctx.requester?.userId ?? "", + }); + }, + beforeToolExecute(ctx) { + ctx.env.set("AGENT_PLUGIN", ctx.requester?.userId ?? ""); + if ( + typeof ctx.tool.input === "object" && + ctx.tool.input && + "command" in ctx.tool.input && + ctx.tool.input.command === "replace me" + ) { + ctx.decision.replaceInput({ + ...ctx.tool.input, + command: "replaced", + }); + } + if ( + typeof ctx.tool.input === "object" && + ctx.tool.input && + "command" in ctx.tool.input && + ctx.tool.input.command === "blocked" + ) { + ctx.decision.deny("blocked by plugin"); + } + }, + }, + }), + ]); + try { + const runner = createAgentPluginHookRunner({ + requester: { userId: "U123" }, + }); + + await runner.prepareSandbox(fakeSandbox(writes)); + expect(writes).toEqual([ + { + path: "/vercel/sandbox/.junior/prepared.txt", + content: "U123", + }, + ]); + + await expect( + runner.beforeToolExecute({ + name: "bash", + input: { command: "blocked" }, + }), + ).rejects.toThrow("blocked by plugin"); + + const before = await runner.beforeToolExecute({ + name: "bash", + input: { command: "replace me" }, + }); + expect(before.input).toEqual({ + command: "replaced", + env: { AGENT_PLUGIN: "U123" }, + }); + expect(before.env).toEqual({ AGENT_PLUGIN: "U123" }); + } finally { + setAgentPlugins(previous); + } + }); +}); diff --git a/packages/junior/tests/unit/tools/execution/build-sandbox-input.test.ts b/packages/junior/tests/unit/tools/execution/build-sandbox-input.test.ts index 414f5555..c301f29b 100644 --- a/packages/junior/tests/unit/tools/execution/build-sandbox-input.test.ts +++ b/packages/junior/tests/unit/tools/execution/build-sandbox-input.test.ts @@ -6,6 +6,15 @@ describe("buildSandboxInput", () => { expect(buildSandboxInput("bash", { command: "ls -la" })).toEqual({ command: "ls -la", }); + expect( + buildSandboxInput("bash", { + command: "env", + env: { AGENT_PLUGIN: "1" }, + }), + ).toEqual({ + command: "env", + env: { AGENT_PLUGIN: "1" }, + }); expect( buildSandboxInput("bash", { command: "sleep 10", timeoutMs: 1000 }), ).toEqual({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aecd665..6d48295e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: "@modelcontextprotocol/sdk": specifier: 1.29.0 version: 1.29.0(zod@4.4.3) + "@sentry/junior-plugin-api": + specifier: workspace:* + version: link:../junior-plugin-api "@sinclair/typebox": specifier: ^0.34.49 version: 0.34.49 @@ -203,6 +206,18 @@ importers: specifier: ^4.1.7 version: 4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/junior-plugin-api: + devDependencies: + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/junior-agent-browser: {} packages/junior-datadog: {} @@ -231,7 +246,11 @@ importers: specifier: 0.11.0 version: 0.11.0(ai@6.0.190(zod@4.4.3))(tinyrainbow@3.1.0)(vitest@4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)))(zod@4.4.3) - packages/junior-github: {} + packages/junior-github: + dependencies: + "@sentry/junior-plugin-api": + specifier: workspace:* + version: link:../junior-plugin-api packages/junior-hex: {} diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index a1a953ba..076c27ad 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -10,6 +10,7 @@ if (!newVersion) { const files = [ "packages/junior/package.json", + "packages/junior-plugin-api/package.json", "packages/junior-agent-browser/package.json", "packages/junior-datadog/package.json", "packages/junior-github/package.json", diff --git a/specs/plugin-spec.md b/specs/plugin-spec.md index 9ade699a..dc1edcf6 100644 --- a/specs/plugin-spec.md +++ b/specs/plugin-spec.md @@ -25,6 +25,7 @@ - 2026-05-08: Added plugin-level `command-env` for non-secret sandbox CLI placeholders, default-backed deployment values, and explicit public host env bindings. - 2026-05-12: Clarified that credentialed provider HTTP traffic is authenticated through the sandbox egress proxy. - 2026-05-20: Added `PluginConfig` manifests for install-level plugin configuration. +- 2026-05-25: Added explicit trusted app plugin registration for deterministic agent behavior at Junior-owned lifecycle boundaries. ## Status @@ -233,6 +234,49 @@ command-env: Snapshot build/reuse and invalidation behavior for `runtime-dependencies` is defined in [Sandbox Snapshots Spec](./sandbox-snapshots-spec.md). +### Trusted app plugin registration + +Trusted agent behavior is initialized from app code, not `plugin.yaml`. +Plugin packages that need deterministic runtime behavior export functions that +return `JuniorPlugin` objects from `@sentry/junior-plugin-api`, and apps pass +those objects to `createApp()`: + +```ts +import { createApp } 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", + }), + ], +}); +``` + +`JuniorPlugin.pluginConfig` may contribute package names that would otherwise be +passed through `PluginConfig.packages`; `JuniorPlugin.hooks` registers trusted +lifecycle code. This keeps declarative plugin metadata inspectable in manifests +while making trusted code execution an explicit app configuration decision. When +deploying with Nitro, `juniorNitro({ plugins })` still owns build-time copying +of package plugin content such as `plugin.yaml` and bundled skills; +`createApp({ plugins: [...] })` owns runtime registration. + +Hook contexts expose narrow capabilities rather than raw Junior internals. +The initial v1 runtime invokes: + +- `sandboxPrepare` after sandbox skill/runtime sync and before agent-visible + sandbox tools execute. Failures fail sandbox setup. +- `beforeToolExecute` before a tool runs. Hooks may mutate tool env/input or + deny execution. + +For GitHub commit attribution, the GitHub plugin uses `sandboxPrepare` to +install a `prepare-commit-msg` hook and configure global Git defaults for the +sandbox, and `beforeToolExecute` injects the bot author and requester coauthor +environment. Git's commit path adds and validates attribution before `git +commit` completes. + Install-level `PluginConfig` manifests apply before validation and registration. Manifest config uses the same logical field names as the public plugin config API, replaces arrays wholesale, merges objects by key, and allows `null` to delete optional fields or map entries. The merged manifest remains subject to the same validation rules as `plugin.yaml`, including unique effective provider domains. ### MCP URL env-var expansion