From a6aae31d280787c4106088e4f9036d625faff029 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 25 May 2026 09:58:24 -0700 Subject: [PATCH] feat(plugins): Add trusted runtime hooks Add a lightweight plugin API package and runtime hook runner so trusted app plugins can prepare sandboxes and adjust tool execution before commands run. Wire createApp to register trusted plugin instances while preserving Nitro-bundled plugin catalogs. Move GitHub commit attribution into the GitHub plugin by installing a prepare-commit-msg hook, configuring sandbox Git defaults, and injecting bot author plus requester coauthor identity before bash commands. Refs GH-414 Co-Authored-By: GPT-5 Codex --- .craft.yml | 3 + .github/workflows/ci.yml | 1 + CONTRIBUTING.md | 1 + README.md | 1 + .../src/content/docs/contribute/releasing.md | 1 + .../src/content/docs/extend/build-a-plugin.md | 77 +++++++ .../src/content/docs/extend/github-plugin.md | 23 +- .../docs/src/content/docs/extend/index.md | 32 ++- .../docs/src/content/docs/reference/api.md | 4 +- .../docs/reference/api/functions/createApp.md | 2 +- .../api/interfaces/JuniorAppOptions.md | 16 +- .../content/docs/start-here/existing-app.md | 6 +- .../src/content/docs/start-here/quickstart.md | 5 + packages/junior-github/README.md | 19 ++ packages/junior-github/index.d.ts | 9 + packages/junior-github/index.js | 150 +++++++++++++ packages/junior-github/package.json | 13 +- .../junior-github/skills/github-code/SKILL.md | 20 +- packages/junior-plugin-api/package.json | 31 +++ packages/junior-plugin-api/src/index.ts | 79 +++++++ .../junior-plugin-api/tsconfig.build.json | 10 + packages/junior-plugin-api/tsconfig.json | 13 ++ packages/junior-plugin-api/tsup.config.ts | 12 + packages/junior/README.md | 2 +- packages/junior/package.json | 1 + .../skills/junior/references/packaging.md | 15 ++ packages/junior/src/app.ts | 87 +++++++- .../junior/src/chat/plugins/agent-hooks.ts | 206 ++++++++++++++++++ packages/junior/src/chat/respond.ts | 8 + .../junior/src/chat/runtime/reply-executor.ts | 1 + packages/junior/src/chat/sandbox/sandbox.ts | 5 + packages/junior/src/chat/sandbox/session.ts | 19 +- packages/junior/src/chat/slack/user.ts | 3 + packages/junior/src/chat/tools/agent-tools.ts | 19 +- .../tools/execution/build-sandbox-input.ts | 5 + packages/junior/tests/unit/app-config.test.ts | 81 +++++++ .../tests/unit/misc/sandbox-executor.test.ts | 21 ++ .../tests/unit/plugins/agent-hooks.test.ts | 130 +++++++++++ .../execution/build-sandbox-input.test.ts | 9 + pnpm-lock.yaml | 21 +- scripts/bump-release-versions.mjs | 1 + specs/plugin-spec.md | 44 ++++ 42 files changed, 1162 insertions(+), 44 deletions(-) create mode 100644 packages/junior-github/index.d.ts create mode 100644 packages/junior-github/index.js create mode 100644 packages/junior-plugin-api/package.json create mode 100644 packages/junior-plugin-api/src/index.ts create mode 100644 packages/junior-plugin-api/tsconfig.build.json create mode 100644 packages/junior-plugin-api/tsconfig.json create mode 100644 packages/junior-plugin-api/tsup.config.ts create mode 100644 packages/junior/src/chat/plugins/agent-hooks.ts create mode 100644 packages/junior/tests/unit/plugins/agent-hooks.test.ts 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