diff --git a/AGENTS.md b/AGENTS.md index 28c18fb6..f1507346 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ Co-Authored-By: (agent model name) - `specs/security-policy.md` (global runtime/container/token security policy) - `specs/chat-architecture-spec.md` (chat composition, service, and test-seam architecture contract) - `specs/slack-agent-delivery-spec.md` (Slack entry surfaces, reply delivery, continuation, files, images, and resume behavior contract) +- `specs/github-agent-delivery-spec.md` (GitHub mention entry surfaces, one-turn comment delivery, and platform/tool boundary contract) - `specs/slack-outbound-contract-spec.md` (Slack outbound boundary, message/file/reaction safety rules, and markdown-to-`mrkdwn` ownership) - `specs/skill-capabilities-spec.md` (capability declaration + broker/injection contract) - `specs/oauth-flows-spec.md` (OAuth authorization code flow + Slack UX contract) diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index 14c5860c..52c31717 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -53,7 +53,7 @@ For reuse across apps or teams, package plugin manifests + skills as npm package pnpm add @sentry/junior @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-sentry ``` -List the plugin packages in `juniorNitro` so they are bundled at build time and available at runtime: +List the plugin packages in `juniorNitro` so they are bundled at build time, then enable the plugin manifest names on each platform that should use them: ```ts title="nitro.config.ts" import { defineConfig } from "nitro"; @@ -71,6 +71,14 @@ export default defineConfig({ "@sentry/junior-notion", "@sentry/junior-sentry", ], + platforms: { + slack: { + plugins: ["datadog", "github", "hex", "linear", "notion", "sentry"], + }, + github: { + plugins: ["github"], + }, + }, }), ], routes: { @@ -271,7 +279,7 @@ Then install it in the host app: pnpm add @acme/junior-example ``` -The `juniorNitro({ pluginPackages: [...] })` 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({ pluginPackages: [...] })` module includes `app/**/*` and the declared plugin package content in the deployed function bundle. Use `platforms..plugins` to decide which bundled providers each chat surface may use. ## Validate extensions diff --git a/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/POST.md b/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/POST.md deleted file mode 100644 index e472c289..00000000 --- a/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/POST.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -editUrl: false -next: false -prev: false -title: "POST" ---- - -> **POST**(`request`, `platform`, `waitUntil`): `Promise`\<`Response`\> - -Defined in: [handlers/webhooks.ts:252](https://github.com/getsentry/junior/blob/main/packages/junior/src/handlers/webhooks.ts#L252) - -## Parameters - -### request - -`Request` - -### platform - -`string` - -### waitUntil - -`WaitUntilFn` - -## Returns - -`Promise`\<`Response`\> diff --git a/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/handlePlatformWebhook.md b/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/handlePlatformWebhook.md index c4fb57c0..054a0352 100644 --- a/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/handlePlatformWebhook.md +++ b/packages/docs/src/content/docs/reference/api/handlers/webhooks/functions/handlePlatformWebhook.md @@ -5,18 +5,17 @@ prev: false title: "handlePlatformWebhook" --- -> **handlePlatformWebhook**(`request`, `platform`, `waitUntil`, `bot?`): `Promise`\<`Response`\> +> **handlePlatformWebhook**(`request`, `platform`, `waitUntil`, `bot`): `Promise`\<`Response`\> -Defined in: [handlers/webhooks.ts:124](https://github.com/getsentry/junior/blob/main/packages/junior/src/handlers/webhooks.ts#L124) +Defined in: [handlers/webhooks.ts:22](https://github.com/getsentry/junior/blob/main/packages/junior/src/handlers/webhooks.ts#L22) Handles `POST /api/webhooks/:platform`. The router only resolves the platform and delegates to the adapter webhook implementation; request semantics stay owned by the adapter package. -For Slack, the body is read once and used to detect `message_changed` events -that introduce a new bot @mention, which the Slack adapter silently ignores. -The request is then reconstructed so the adapter can consume it normally. +Platform-owned preprocessors may rebuild the request before delegation when +an adapter has side-channel behavior the generic router should not own. ## Parameters @@ -32,9 +31,9 @@ The request is then reconstructed so the adapter can consume it normally. `WaitUntilFn` -### bot? +### bot -`JuniorChat`\<\{ `slack`: `SlackAdapter`; \}\> = `...` +`ProductionBot` ## Returns diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 94d8e1df..f50dee11 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -12,18 +12,70 @@ related: ## Core runtime -| Variable | Required | Purpose | -| ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. | -| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. | -| `REDIS_URL` | Yes | Queue and runtime state storage. | -| `JUNIOR_BOT_NAME` | No | Bot display/config naming. | -| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. | -| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. | -| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. | -| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | -| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | -| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | +| Variable | Required | Purpose | +| ------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SLACK_SIGNING_SECRET` | Conditional | Verifies Slack request signatures. Required when `slack` is enabled via `enabledPlatforms` or `platforms`. | +| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Conditional | Posts thread replies and calls Slack APIs. Required when `slack` is enabled via `enabledPlatforms` or `platforms`. | +| `REDIS_URL` | Yes | Queue and runtime state storage. | +| `JUNIOR_BOT_NAME` | No | Bot display/config naming. | +| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. | +| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. | +| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. | +| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | +| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | +| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | + +## Chat ingress platforms + +Slack is enabled by default. Configure chat ingress platforms in the app initializer: + +```ts +import { createApp } from "@sentry/junior"; + +const app = await createApp({ + enabledPlatforms: ["slack", "github"], +}); +``` + +Use `enabledPlatforms: ["github"]` for a GitHub-only deployment without Slack. + +You can also put the same setting in `juniorNitro(...)`; `createApp()` reads that build-time config automatically. + +For one deployment that serves multiple platforms, prefer per-platform configuration: + +```ts +juniorNitro({ + pluginPackages: ["@sentry/junior-github", "@sentry/junior-sentry"], + platforms: { + slack: { + plugins: ["github", "sentry"], + skills: ["github-issues", "sentry-issues"], + }, + github: { + plugins: ["github"], + skills: ["github-issues", "github-pr-review"], + }, + }, +}); +``` + +`pluginPackages` controls what plugin package content is bundled. `platforms..plugins` controls which plugin providers that platform can use at runtime. `platforms..skills` is an optional exact skill allowlist. + +## GitHub mention webhook (optional, V1) + +Enable this when you want inbound GitHub mentions to run Junior through `/api/webhooks/github`. + +Add `github` to `createApp({ enabledPlatforms })`, `juniorNitro({ enabledPlatforms })`, or `platforms.github` before adding these values. + +| Variable | Required | Purpose | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------- | +| `GITHUB_APP_ID` | Yes | GitHub App identity. | +| `GITHUB_APP_PRIVATE_KEY` | Yes | GitHub App signing key used for GitHub API calls. | +| `GITHUB_INSTALLATION_ID` | Yes | Initial single-installation target for V1 mention handling. | +| `GITHUB_WEBHOOK_SECRET` | Yes | Verifies GitHub webhook signatures (`x-hub-signature-256`). | +| `GITHUB_BOT_USERNAME` | Yes | Mention handle Junior listens for in issue/PR/review comment webhooks, without a leading `@`. | + +V1 behavior is mention-only. Untagged GitHub comments do not trigger a turn. ## Build-time snapshot warmup @@ -59,7 +111,7 @@ The egress proxy verifies Vercel-signed Sandbox OIDC tokens per request and bind | `SENTRY_CLIENT_ID` | Yes | OAuth client ID. | | `SENTRY_CLIENT_SECRET` | Yes | OAuth client secret. | -## Install-wide config defaults +## Config defaults Pass `configDefaults` to `createApp()` to set provider defaults across all conversations: @@ -75,13 +127,35 @@ const app = await createApp({ }); ``` -Keys must be registered plugin config keys. Channel-scoped overrides (`jr-rpc config set`) take precedence. +Use `platforms..configDefaults` when Slack and GitHub need different defaults in the same deployment: + +```ts +juniorNitro({ + platforms: { + slack: { + plugins: ["sentry"], + configDefaults: { + "sentry.org": "sentry", + }, + }, + github: { + plugins: ["github"], + configDefaults: { + "github.repo": "myorg/myrepo", + }, + }, + }, +}); +``` + +Keys must be registered plugin config keys. Channel-scoped overrides (`jr-rpc config set`) take precedence over platform defaults, and platform defaults take precedence over install-wide defaults. ## Verification - Validate required variables exist in deployment environment. - Redeploy after variable changes. - Run one end-to-end Slack thread action per enabled integration. +- If GitHub webhook mode is enabled, send one explicit `@` mention in a supported GitHub comment and confirm one final comment reply. ## Next step diff --git a/packages/docs/src/content/docs/reference/handler-surface.md b/packages/docs/src/content/docs/reference/handler-surface.md index 966f85eb..7e1c4bf0 100644 --- a/packages/docs/src/content/docs/reference/handler-surface.md +++ b/packages/docs/src/content/docs/reference/handler-surface.md @@ -18,19 +18,23 @@ Handled `GET` routes: - `/` - `/health` - `/api/info` -- `/api/oauth/callback/:provider` -- `/api/oauth/callback/mcp/:provider` +- `/api/oauth/callback/:provider` when Slack ingress is enabled +- `/api/oauth/callback/mcp/:provider` when Slack ingress is enabled Handled `POST` routes: -- `/api/internal/turn-resume` -- `/api/webhooks/:platform` (Slack path is `/api/webhooks/slack`) +- `/api/internal/turn-resume` when Slack ingress is enabled +- `/api/webhooks/:platform` + - Slack: `/api/webhooks/slack` + - GitHub mention webhook (V1): `/api/webhooks/github` ## Expected behavior - Unknown routes return `404`. +- Disabled webhook platforms return `404` without initializing that platform's bot adapter. - Queue callback validates queue topic and processes thread work. - Webhook handler logs and surfaces non-success behavior for operators. +- GitHub webhook entry is mention-only in V1: explicit `@` tags in issue/PR/review comments trigger one turn; untagged comments are ignored. ## Next step diff --git a/packages/docs/src/content/docs/start-here/quickstart.md b/packages/docs/src/content/docs/start-here/quickstart.md index 2d944cbf..196c6688 100644 --- a/packages/docs/src/content/docs/start-here/quickstart.md +++ b/packages/docs/src/content/docs/start-here/quickstart.md @@ -13,6 +13,7 @@ related: - Node.js 20+ - pnpm - A Slack app with signing secret + bot token +- (Optional) A GitHub App for inbound mention webhooks - Redis URL - A Vercel account @@ -43,12 +44,22 @@ For a new app, you usually do not need to hand-create routes or runtime wrapper Copy values into your local env file. The scaffold includes `.env.example` with the core runtime variables. -Required: +Required for the default Slack ingress: - `SLACK_SIGNING_SECRET` - `SLACK_BOT_TOKEN` - `REDIS_URL` +For a GitHub-only deployment, use `createApp({ enabledPlatforms: ["github"] })` or `juniorNitro({ enabledPlatforms: ["github"] })`, then omit the Slack variables. + +Optional for GitHub mention webhook ingress (V1): + +- `GITHUB_APP_ID` +- `GITHUB_APP_PRIVATE_KEY` +- `GITHUB_INSTALLATION_ID` +- `GITHUB_WEBHOOK_SECRET` +- `GITHUB_BOT_USERNAME` + Recommended: - `JUNIOR_BOT_NAME` @@ -73,6 +84,7 @@ Check the health route first, then verify a real Slack thread. - `GET http://localhost:3000/health` returns JSON with `status: "ok"`. - Set your Slack Event Subscriptions and Interactivity URLs to `http:///api/webhooks/slack`. - Mention the bot in Slack and confirm it replies in the same thread. +- If GitHub ingress is enabled, set your GitHub App webhook URL to `http:///api/webhooks/github`, then post an explicit `@` mention in an issue/PR/review comment and confirm one final reply comment. ## Add plugins @@ -184,6 +196,7 @@ Optional: - `JUNIOR_BASE_URL` - `AI_GATEWAY_API_KEY` +- GitHub mention webhook env vars listed earlier (only if GitHub ingress is enabled) ### Configure Slack request URL @@ -193,10 +206,42 @@ Set Event Subscriptions and Interactivity URLs to: https:///api/webhooks/slack ``` +### Configure GitHub webhook URL (optional, mention-only V1) + +Enable GitHub in the app initializer before configuring this webhook: + +```ts +const app = await createApp({ + enabledPlatforms: ["slack", "github"], +}); +``` + +If you keep deployment wiring in `nitro.config.ts`, use `juniorNitro({ enabledPlatforms: ["slack", "github"] })` instead and leave `createApp()` without platform options. + +For one deployment where Slack and GitHub should expose different plugins or skills, use `juniorNitro({ platforms: { slack: { plugins: [...] }, github: { plugins: [...] } } })` instead of `enabledPlatforms`. + +Set your GitHub App webhook URL to: + +```text +https:///api/webhooks/github +``` + +Subscribe to: + +- `issue_comment` +- `pull_request_review_comment` + +V1 behavior: + +- Junior runs only on explicit `@` mentions in those comment surfaces. +- Untagged comments are ignored. +- Delivery is one final GitHub comment reply (no streaming/status surface). + ### Verify in production - `GET https:///health` succeeds. - A Slack mention produces a thread reply. +- If GitHub ingress is enabled, an explicit GitHub mention in a supported comment surface produces one final reply comment. - Queue callback logs show successful processing. ## Common failures diff --git a/packages/junior-evals/evals/behavior-harness.ts b/packages/junior-evals/evals/behavior-harness.ts index c90f2bb2..d39b99be 100644 --- a/packages/junior-evals/evals/behavior-harness.ts +++ b/packages/junior-evals/evals/behavior-harness.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Message } from "chat"; -import { createSlackRuntime } from "@/chat/app/factory"; +import { createGitHubRuntime, createSlackRuntime } from "@/chat/app/factory"; import type { AssistantLifecycleEvent } from "@/chat/runtime/slack-runtime"; import type { JuniorRuntimeServiceOverrides } from "@/chat/app/services"; import { createUserTokenStore } from "@/chat/capabilities/factory"; @@ -76,6 +76,11 @@ interface MentionEvent extends EvalBaseEvent { type: "new_mention"; } +interface GitHubMentionEvent extends EvalBaseEvent { + message: EvalEventMessageFixture; + type: "github_mention"; +} + interface SubscribedMessageEvent extends EvalBaseEvent { message: EvalEventMessageFixture; type: "subscribed_message"; @@ -93,6 +98,7 @@ interface AssistantContextChangedEvent extends EvalBaseEvent { export type EvalEvent = | MentionEvent + | GitHubMentionEvent | SubscribedMessageEvent | AssistantThreadStartedEvent | AssistantContextChangedEvent; @@ -1078,6 +1084,7 @@ function buildRuntimeServices( async function processEvents(args: { scenario: EvalScenario; env: HarnessEnvironment; + githubRuntime: ReturnType; slackRuntime: ReturnType; dispatch: ReturnType; getThreadRecord: (fixture: EvalEventThreadFixture) => EvalThreadRecord; @@ -1086,6 +1093,7 @@ async function processEvents(args: { const { scenario, env, + githubRuntime, slackRuntime, dispatch, getThreadRecord, @@ -1141,6 +1149,16 @@ async function processEvents(args: { readyQueueDeliveries.push({ kind, message, thread }); }; + const runGitHubMention = async (event: GitHubMentionEvent): Promise => { + const { thread, transcript } = getThreadRecord(event.thread); + const message = toIncomingMessage({ + ...event, + type: "new_mention", + }) as unknown as Message; + upsertThreadTranscriptMessage(transcript, message); + await githubRuntime.handleNewMention(thread, message); + }; + const runLifecycleEvent = async ( event: AssistantThreadStartedEvent | AssistantContextChangedEvent, ): Promise => { @@ -1160,6 +1178,8 @@ async function processEvents(args: { for (const event of scenario.events) { if (event.type === "new_mention" || event.type === "subscribed_message") { enqueueEvent(event); + } else if (event.type === "github_mention") { + await runGitHubMention(event); } else { await runLifecycleEvent(event); } @@ -1291,12 +1311,14 @@ export async function runEvalScenario( getSlackAdapter: () => slackAdapter as any, services, }); + const githubRuntime = createGitHubRuntime({ services }); const dispatch = createThreadMessageDispatcher({ runtime: slackRuntime }); try { await processEvents({ scenario, env, + githubRuntime, slackRuntime, dispatch, getThreadRecord, diff --git a/packages/junior-evals/evals/github/surface-contract.eval.ts b/packages/junior-evals/evals/github/surface-contract.eval.ts new file mode 100644 index 00000000..5c832562 --- /dev/null +++ b/packages/junior-evals/evals/github/surface-contract.eval.ts @@ -0,0 +1,31 @@ +import { describeEval } from "vitest-evals"; +import { githubMention, rubric, slackEvals } from "../helpers"; + +describeEval("GitHub Surface Contract", slackEvals, (it) => { + it("when replying from a GitHub mention, stay on the GitHub comment surface", async ({ + run, + }) => { + await run({ + events: [ + githubMention( + "The failing check says `Cannot find module '@/chat/slack/tools'`. Summarize the likely cleanup in two concise bullets. Reply here in GitHub.", + ), + ], + criteria: rubric({ + contract: + "A GitHub mention produces a normal GitHub comment reply without Slack-only side effects or formatting.", + pass: [ + "assistant_posts contains exactly one reply.", + "The reply is useful GitHub-flavored Markdown for the requested summary.", + "channel_posts, reactions, and canvases are empty.", + "slack_metadata.assistant_status_pending is false.", + ], + fail: [ + "Do not include Slack mention or channel markup such as `<@...>` or `<#...>`.", + "Do not claim to post a Slack message, Slack reaction, or Slack canvas.", + "Do not ask the user to retry from Slack for this normal non-auth reply.", + ], + }), + }); + }); +}); diff --git a/packages/junior-evals/evals/helpers.ts b/packages/junior-evals/evals/helpers.ts index dbbc9d7b..5c704e3b 100644 --- a/packages/junior-evals/evals/helpers.ts +++ b/packages/junior-evals/evals/helpers.ts @@ -456,6 +456,34 @@ export function mention( }; } +/** Builds a first-turn mention event for a harnessed GitHub comment eval. */ +export function githubMention( + text: string, + opts?: { author?: AuthorOverrides; thread?: ThreadOverrides }, +) { + const seq = nextId(); + return { + type: "github_mention" as const, + thread: { + id: `github:issue:acme/junior:${seq}`, + ...opts?.thread, + }, + message: { + id: `github-m-${seq}`, + text, + is_mention: true, + author: { + user_id: `github-user-${seq}`, + user_name: "github-testuser", + full_name: "GitHub Test User", + is_me: false, + is_bot: false, + ...opts?.author, + }, + }, + }; +} + /** Builds a follow-up subscribed-thread message for a harnessed Slack eval. */ export function threadMessage( text: string, diff --git a/packages/junior/.env.example b/packages/junior/.env.example index d7e8589d..23b98f2c 100644 --- a/packages/junior/.env.example +++ b/packages/junior/.env.example @@ -12,4 +12,6 @@ SKILL_DIRS= # Additional skill directories (colon-separated) GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= GITHUB_INSTALLATION_ID= +GITHUB_WEBHOOK_SECRET= +GITHUB_BOT_USERNAME= # GitHub @mention target, for example "junior" SENTRY_DSN= diff --git a/packages/junior/package.json b/packages/junior/package.json index 061c4501..4251930f 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@ai-sdk/gateway": "^3.0.110", + "@chat-adapter/github": "4.28.1", "@chat-adapter/slack": "4.28.1", "@chat-adapter/state-memory": "4.28.1", "@chat-adapter/state-redis": "4.28.1", @@ -50,8 +51,8 @@ "bash-tool": "^1.3.16", "chat": "4.28.1", "hono": "^4.12.6", - "just-bash": "2.14.2", "jose": "^6.2.2", + "just-bash": "2.14.2", "node-html-markdown": "^2.0.0", "yaml": "^2.8.4", "zod": "^4.4.3" diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 86899308..3cd69dd2 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -1,24 +1,64 @@ import { Hono } from "hono"; import { setConfigDefaults } from "@/chat/configuration/defaults"; import { logException } from "@/chat/logging"; +import type { ProductionBot } from "@/chat/app/production"; +import type { ChatPlatform } from "@/chat/platforms"; +import { + resolvePlatformConfig, + validatePlatformConfig, + type JuniorPlatformOptionsMap, +} from "@/chat/platform-config"; import { setPluginPackages } from "@/chat/plugins/package-discovery"; import { GET as diagnosticsGET } from "@/handlers/diagnostics"; import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard"; import { GET as healthGET } from "@/handlers/health"; -import { GET as mcpOauthCallbackGET } from "@/handlers/mcp-oauth-callback"; -import { GET as oauthCallbackGET } from "@/handlers/oauth-callback"; import { ALL as sandboxEgressProxyALL } from "@/handlers/sandbox-egress-proxy"; -import { POST as turnResumePOST } from "@/handlers/turn-resume"; -import { POST as webhooksPOST } from "@/handlers/webhooks"; +import { handlePlatformWebhook } from "@/handlers/webhooks"; import type { WaitUntilFn } from "@/handlers/types"; export interface JuniorAppOptions { /** Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. */ configDefaults?: Record; + /** Chat ingress platforms to enable for this app. Defaults to Slack only. */ + enabledPlatforms?: readonly ChatPlatform[]; + /** Per-platform plugin, skill, and config defaults. Keys also enable platforms. */ + platforms?: JuniorPlatformOptionsMap; pluginPackages?: string[]; waitUntil?: WaitUntilFn; } +interface JuniorBuildConfig { + enabledPlatforms?: string[]; + platforms?: JuniorPlatformOptionsMap; + pluginPackages?: string[]; +} + +function isMissingVirtualConfig(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const code = (error as { code?: unknown }).code; + if (code === "ERR_MODULE_NOT_FOUND") { + return true; + } + const message = (error as { message?: unknown }).message; + return ( + typeof message === "string" && + /#junior\/config|junior\/config/.test(message) + ); +} + +function parsePluginPackagesEnv(rawValue: string): string[] { + const parsed: unknown = JSON.parse(rawValue); + if ( + !Array.isArray(parsed) || + parsed.some((packageName) => typeof packageName !== "string") + ) { + throw new Error("JUNIOR_PLUGIN_PACKAGES must be a JSON array of strings"); + } + return parsed; +} + /** Build a `WaitUntilFn`, preferring Vercel's lifetime extension when available. */ async function defaultWaitUntil(): Promise { try { @@ -36,30 +76,56 @@ async function defaultWaitUntil(): Promise { } } -/** Resolve plugin packages from the virtual module injected by juniorNitro(). */ -async function resolveBuildPluginPackages(): Promise { +/** Resolve build-time config injected by juniorNitro(). */ +async function resolveBuildConfig(): Promise { try { - const mod: { pluginPackages?: string[] } = await import("#junior/config"); - return mod.pluginPackages; - } catch { + const mod: JuniorBuildConfig = await import("#junior/config"); + return mod; + } catch (error) { + if (!isMissingVirtualConfig(error)) { + throw error; + } // Virtual module unavailable (not running in Nitro context). // Fall back to env var for dev mode and tests. const env = process.env.JUNIOR_PLUGIN_PACKAGES; if (env) { - try { - return JSON.parse(env); - } catch {} + return { pluginPackages: parsePluginPackagesEnv(env) }; } - return undefined; + return {}; } } /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { - setPluginPackages( - options?.pluginPackages ?? (await resolveBuildPluginPackages()), - ); + const buildConfig = await resolveBuildConfig(); + const appPlatformInput = + options?.enabledPlatforms !== undefined || options?.platforms !== undefined + ? { + enabledPlatforms: options.enabledPlatforms, + platforms: options.platforms, + } + : { + enabledPlatforms: buildConfig.enabledPlatforms, + platforms: buildConfig.platforms, + }; + const { enabledPlatforms, platformConfigs } = resolvePlatformConfig({ + enabledPlatforms: appPlatformInput.enabledPlatforms, + platforms: appPlatformInput.platforms, + }); + let getBotPromise: Promise<() => ProductionBot> | undefined; + const resolveBot = async (): Promise => { + getBotPromise ??= import("@/chat/app/production").then( + ({ createProductionBotResolver }) => + createProductionBotResolver({ enabledPlatforms, platformConfigs }), + ); + const getBot = await getBotPromise; + return getBot(); + }; + const slackEnabled = enabledPlatforms.includes("slack"); + + setPluginPackages(options?.pluginPackages ?? buildConfig.pluginPackages); setConfigDefaults(options?.configDefaults); + await validatePlatformConfig(platformConfigs); const waitUntil = options?.waitUntil ?? (await defaultWaitUntil()); @@ -79,17 +145,22 @@ export async function createApp(options?: JuniorAppOptions): Promise { // MCP callback must be registered before the generic OAuth callback // because Hono matches routes top-down and `:provider` would swallow `mcp/`. - app.get("/api/oauth/callback/mcp/:provider", (c) => { - return mcpOauthCallbackGET(c.req.raw, c.req.param("provider"), waitUntil); - }); + if (slackEnabled) { + app.get("/api/oauth/callback/mcp/:provider", async (c) => { + const { GET } = await import("@/handlers/mcp-oauth-callback"); + return GET(c.req.raw, c.req.param("provider"), waitUntil); + }); - app.get("/api/oauth/callback/:provider", (c) => { - return oauthCallbackGET(c.req.raw, c.req.param("provider"), waitUntil); - }); + app.get("/api/oauth/callback/:provider", async (c) => { + const { GET } = await import("@/handlers/oauth-callback"); + return GET(c.req.raw, c.req.param("provider"), waitUntil); + }); - app.post("/api/internal/turn-resume", (c) => { - return turnResumePOST(c.req.raw, waitUntil); - }); + app.post("/api/internal/turn-resume", async (c) => { + const { POST } = await import("@/handlers/turn-resume"); + return POST(c.req.raw, waitUntil); + }); + } app.all("/api/internal/sandbox-egress/:egressId", (c) => { return sandboxEgressProxyALL(c.req.raw, c.req.param("egressId")); @@ -98,8 +169,17 @@ export async function createApp(options?: JuniorAppOptions): Promise { return sandboxEgressProxyALL(c.req.raw, c.req.param("egressId")); }); - app.post("/api/webhooks/:platform", (c) => { - return webhooksPOST(c.req.raw, c.req.param("platform"), waitUntil); + app.post("/api/webhooks/:platform", async (c) => { + const platform = c.req.param("platform"); + if (!enabledPlatforms.includes(platform as ChatPlatform)) { + return c.text(`Unknown platform: ${platform}`, 404); + } + return handlePlatformWebhook( + c.req.raw, + platform, + waitUntil, + await resolveBot(), + ); }); return app; diff --git a/packages/junior/src/build/virtual-config.ts b/packages/junior/src/build/virtual-config.ts index b6427662..51ecccd0 100644 --- a/packages/junior/src/build/virtual-config.ts +++ b/packages/junior/src/build/virtual-config.ts @@ -1,10 +1,21 @@ import type { Nitro } from "nitro/types"; +import type { ChatPlatform } from "@/chat/platforms"; +import type { JuniorPlatformOptionsMap } from "@/chat/platform-config"; -/** Inject a virtual module so createApp() can read the plugin list at runtime. */ +export interface VirtualJuniorConfig { + enabledPlatforms?: readonly ChatPlatform[]; + pluginPackages?: string[]; + platforms?: JuniorPlatformOptionsMap; +} + +/** Inject a virtual module so createApp() can read build-time config at runtime. */ export function injectVirtualConfig( nitro: Nitro, - pluginPackages: string[], + config: VirtualJuniorConfig, ): void { - nitro.options.virtual["#junior/config"] = - `export const pluginPackages = ${JSON.stringify(pluginPackages)};`; + nitro.options.virtual["#junior/config"] = [ + `export const pluginPackages = ${JSON.stringify(config.pluginPackages ?? [])};`, + `export const enabledPlatforms = ${JSON.stringify(config.enabledPlatforms)};`, + `export const platforms = ${JSON.stringify(config.platforms)};`, + ].join("\n"); } diff --git a/packages/junior/src/chat/app/factory.ts b/packages/junior/src/chat/app/factory.ts index fe128317..51da7934 100644 --- a/packages/junior/src/chat/app/factory.ts +++ b/packages/junior/src/chat/app/factory.ts @@ -4,12 +4,18 @@ import { type AssistantLifecycleEvent, type SlackTurnRuntime, } from "@/chat/runtime/slack-runtime"; +import { + createGitHubTurnRuntime, + type GitHubTurnRuntime, +} from "@/chat/github/runtime"; +import type { PlatformRuntimeConfig } from "@/chat/platform-config"; import { createJuniorRuntimeServices } from "@/chat/app/services"; import type { JuniorRuntimeServiceOverrides } from "@/chat/app/services"; import { coerceThreadConversationState } from "@/chat/state/conversation"; import { coerceThreadArtifactsState } from "@/chat/state/artifacts"; import { logException, logWarn, withSpan } from "@/chat/logging"; import { createReplyToThread } from "@/chat/runtime/reply-executor"; +import { createReplyToGitHubThread } from "@/chat/github/reply-executor"; import { initializeAssistantThread as initializeAssistantThreadImpl, refreshAssistantThreadContext as refreshAssistantThreadContextImpl, @@ -43,6 +49,13 @@ import { hasPotentialImageAttachment } from "@/chat/services/vision-context"; export interface CreateSlackRuntimeOptions { getSlackAdapter: () => SlackAdapter; now?: () => number; + platformConfig?: PlatformRuntimeConfig; + services?: JuniorRuntimeServiceOverrides; +} + +export interface CreateGitHubRuntimeOptions { + now?: () => number; + platformConfig?: PlatformRuntimeConfig; services?: JuniorRuntimeServiceOverrides; } @@ -75,6 +88,7 @@ export function createSlackRuntime( getSlackAdapter: options.getSlackAdapter, prepareTurnState, resolveUserAttachments: services.visionContext.resolveUserAttachments, + platformConfig: options.platformConfig, services: services.replyExecutor, }); @@ -201,3 +215,32 @@ export function createSlackRuntime( }, }); } + +export function createGitHubRuntime( + options: CreateGitHubRuntimeOptions = {}, +): GitHubTurnRuntime { + const services = createJuniorRuntimeServices(options.services); + const prepareTurnState = createPrepareTurnState({ + compactConversationIfNeeded: + services.conversationMemory.compactConversationIfNeeded, + hydrateConversationVisionContext: + services.visionContext.hydrateConversationVisionContext, + }); + const replyToThread = createReplyToGitHubThread({ + prepareTurnState, + platformConfig: options.platformConfig, + services: { + generateAssistantReply: services.replyExecutor.generateAssistantReply, + }, + }); + + return createGitHubTurnRuntime({ + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + getThreadId, + getRunId, + logException, + withSpan, + replyToThread, + }); +} diff --git a/packages/junior/src/chat/app/production.ts b/packages/junior/src/chat/app/production.ts index fd6fc5bc..91044fb7 100644 --- a/packages/junior/src/chat/app/production.ts +++ b/packages/junior/src/chat/app/production.ts @@ -1,16 +1,27 @@ +import type { GitHubAdapter } from "@chat-adapter/github"; import type { SlackAdapter } from "@chat-adapter/slack"; -import { createSlackRuntime } from "@/chat/app/factory"; +import type { Adapter } from "chat"; +import { createGitHubRuntime, createSlackRuntime } from "@/chat/app/factory"; import { createUserTokenStore } from "@/chat/capabilities/factory"; import { botConfig, + getGitHubAppId, + getGitHubAppPrivateKey, + getGitHubBotUsername, + getGitHubInstallationId, + getGitHubWebhookSecret, getSlackBotToken, getSlackClientId, getSlackClientSecret, getSlackSigningSecret, } from "@/chat/config"; +import { DEFAULT_CHAT_PLATFORMS, type ChatPlatform } from "@/chat/platforms"; +import type { PlatformRuntimeConfigMap } from "@/chat/platform-config"; import { unlinkProvider } from "@/chat/credentials/unlink-provider"; import { JuniorChat } from "@/chat/ingress/junior-chat"; import { createChatSdkLogger, logException, withSpan } from "@/chat/logging"; +import { createJuniorGitHubAdapter } from "@/chat/github/adapter"; +import { normalizeGitHubMentionTarget } from "@/chat/github/mention"; import { publishAppHomeView } from "@/chat/slack/app-home"; import { createJuniorSlackAdapter } from "@/chat/slack/adapter"; import { getSlackClient } from "@/chat/slack/client"; @@ -18,12 +29,99 @@ import { rehydrateAttachmentFetchers } from "@/chat/queue/thread-message-dispatc import { handleSlashCommand } from "@/chat/ingress/slash-command"; import { getStateAdapter } from "@/chat/state/adapter"; -let productionBot: JuniorChat<{ slack: SlackAdapter }> | undefined; -let productionSlackRuntime: ReturnType | undefined; +export type ProductionBot = JuniorChat>; -function createProductionBot(): JuniorChat<{ slack: SlackAdapter }> { +function getAdapterName(thread: { + adapter?: { name?: string }; +}): string | undefined { + return thread.adapter?.name; +} + +function createProductionSlackAdapter( + logger: ReturnType, +): SlackAdapter { + const signingSecret = getSlackSigningSecret(); + const botToken = getSlackBotToken(); + const clientId = getSlackClientId(); + const clientSecret = getSlackClientSecret(); + + if (!signingSecret) { + throw new Error("SLACK_SIGNING_SECRET is required when Slack is enabled"); + } + + return createJuniorSlackAdapter({ + logger: logger.child("slack"), + signingSecret, + ...(botToken ? { botToken } : {}), + ...(clientId ? { clientId } : {}), + ...(clientSecret ? { clientSecret } : {}), + }); +} + +function createProductionGitHubAdapter( + logger: ReturnType, +): GitHubAdapter { + const appId = getGitHubAppId(); + const privateKey = getGitHubAppPrivateKey(); + const installationId = getGitHubInstallationId(); + const webhookSecret = getGitHubWebhookSecret(); + const botUsername = getGitHubBotUsername(); + const mentionTarget = botUsername + ? normalizeGitHubMentionTarget(botUsername) + : undefined; + + const missing: string[] = []; + if (!appId) missing.push("GITHUB_APP_ID"); + if (!privateKey) missing.push("GITHUB_APP_PRIVATE_KEY"); + // V1 production wiring is intentionally single-installation. The adapter can + // support webhook-derived installations later, but that needs an explicit + // multi-tenant config and auth story instead of happening accidentally. + if (installationId === undefined) missing.push("GITHUB_INSTALLATION_ID"); + if (!webhookSecret) missing.push("GITHUB_WEBHOOK_SECRET"); + if (!mentionTarget) missing.push("GITHUB_BOT_USERNAME"); + if (missing.length > 0) { + throw new Error( + `GitHub adapter requires ${missing.join(", ")} when GitHub webhook support is enabled`, + ); + } + + const requiredConfig = { + appId: appId as string, + privateKey: privateKey as string, + installationId: installationId as number, + webhookSecret: webhookSecret as string, + userName: mentionTarget as string, + }; + + return createJuniorGitHubAdapter({ + ...requiredConfig, + logger: logger.child("github"), + }); +} + +function includesPlatform( + enabledPlatforms: readonly ChatPlatform[], + platform: ChatPlatform, +): boolean { + return enabledPlatforms.includes(platform); +} + +function createProductionBot( + enabledPlatforms: readonly ChatPlatform[], +): ProductionBot { const logger = createChatSdkLogger(); - return new JuniorChat<{ slack: SlackAdapter }>({ + const adapters: Record = {}; + if (includesPlatform(enabledPlatforms, "slack")) { + adapters.slack = createProductionSlackAdapter(logger); + } + if (includesPlatform(enabledPlatforms, "github")) { + adapters.github = createProductionGitHubAdapter(logger); + } + if (Object.keys(adapters).length === 0) { + throw new Error("At least one chat platform must be enabled"); + } + + return new JuniorChat>({ userName: botConfig.userName, logger, concurrency: { @@ -35,26 +133,7 @@ function createProductionBot(): JuniorChat<{ slack: SlackAdapter }> { // maximum turn duration so queued messages survive. queueEntryTtlMs: botConfig.turnTimeoutMs + 60_000, }, - adapters: { - slack: (() => { - const signingSecret = getSlackSigningSecret(); - const botToken = getSlackBotToken(); - const clientId = getSlackClientId(); - const clientSecret = getSlackClientSecret(); - - if (!signingSecret) { - throw new Error("SLACK_SIGNING_SECRET is required"); - } - - return createJuniorSlackAdapter({ - logger: logger.child("slack"), - signingSecret, - ...(botToken ? { botToken } : {}), - ...(clientId ? { clientId } : {}), - ...(clientSecret ? { clientSecret } : {}), - }); - })(), - }, + adapters, state: getStateAdapter(), }); } @@ -63,13 +142,31 @@ function createProductionBot(): JuniorChat<{ slack: SlackAdapter }> { // they hit a safe boundary. MCP auth pauses remain retryable too, // resumed via the OAuth callback path. function registerProductionHandlers( - bot: JuniorChat<{ slack: SlackAdapter }>, - slackRuntime: ReturnType, + bot: ProductionBot, + runtimes: { + github?: ReturnType; + slack?: ReturnType; + }, + options?: { + slackPlatformConfig?: PlatformRuntimeConfigMap["slack"]; + }, ): void { bot.onNewMention((thread, message) => { - rehydrateAttachmentFetchers(message); - return slackRuntime.handleNewMention(thread, message); + const adapterName = getAdapterName(thread); + if (adapterName === "github" && runtimes.github) { + return runtimes.github.handleNewMention(thread, message); + } + if (adapterName === "slack" && runtimes.slack) { + rehydrateAttachmentFetchers(message); + return runtimes.slack.handleNewMention(thread, message); + } + return; }); + if (!runtimes.slack) { + return; + } + const slackRuntime = runtimes.slack; + // Route DMs through the mention handler so every DM gets a reply. // Without this, the SDK routes DMs in subscribed threads to // onSubscribedMessage (Chat.dispatchToHandlers checks isSubscribed @@ -78,10 +175,16 @@ function registerProductionHandlers( // checked first (Chat.dispatchToHandlers:3128), bypassing the // subscription branch entirely. bot.onDirectMessage((thread, message) => { + if (getAdapterName(thread) !== "slack") { + return; + } rehydrateAttachmentFetchers(message); return slackRuntime.handleNewMention(thread, message); }); bot.onSubscribedMessage((thread, message) => { + if (getAdapterName(thread) !== "slack") { + return; + } rehydrateAttachmentFetchers(message); return slackRuntime.handleSubscribedMessage(thread, message); }); @@ -121,6 +224,7 @@ function registerProductionHandlers( getSlackClient(), event.userId, createUserTokenStore(), + options?.slackPlatformConfig, ); } catch (error) { logException(error, "app_home_opened_failed", { @@ -146,6 +250,7 @@ function registerProductionHandlers( getSlackClient(), userId, createUserTokenStore(), + options?.slackPlatformConfig, ); } catch (error) { logException( @@ -162,30 +267,60 @@ function registerProductionHandlers( }); } -function initializeProductionApp(): void { - if (productionBot && productionSlackRuntime) { - return; - } +export interface ProductionBotOptions { + enabledPlatforms?: readonly ChatPlatform[]; + platformConfigs?: PlatformRuntimeConfigMap; +} - const bot = createProductionBot(); - const registerSingleton = ( - bot as unknown as { registerSingleton?: () => unknown } - ).registerSingleton; - if (typeof registerSingleton === "function") { - registerSingleton.call(bot); +/** Create an app-scoped lazy production bot resolver. */ +export function createProductionBotResolver( + options: ProductionBotOptions = {}, +): () => ProductionBot { + const enabledPlatforms = options.enabledPlatforms + ? [...options.enabledPlatforms] + : options.platformConfigs + ? (Object.keys(options.platformConfigs) as ChatPlatform[]) + : [...DEFAULT_CHAT_PLATFORMS]; + if (enabledPlatforms.length === 0) { + throw new Error("At least one production chat platform must be enabled"); } + const platformConfigs = options.platformConfigs ?? {}; + let productionBot: ProductionBot | undefined; - const slackRuntime = createSlackRuntime({ - getSlackAdapter: () => bot.getAdapter("slack"), - }); + return () => { + if (productionBot) { + return productionBot; + } - registerProductionHandlers(bot, slackRuntime); - productionBot = bot; - productionSlackRuntime = slackRuntime; -} + const bot = createProductionBot(enabledPlatforms); + const registerSingleton = ( + bot as unknown as { registerSingleton?: () => unknown } + ).registerSingleton; + if (typeof registerSingleton === "function") { + registerSingleton.call(bot); + } + + const slackRuntime = includesPlatform(enabledPlatforms, "slack") + ? createSlackRuntime({ + getSlackAdapter: () => bot.getAdapter("slack") as SlackAdapter, + platformConfig: platformConfigs.slack, + }) + : undefined; + const githubRuntime = includesPlatform(enabledPlatforms, "github") + ? createGitHubRuntime({ platformConfig: platformConfigs.github }) + : undefined; -/** Return the lazily initialized production chat app. */ -export function getProductionBot(): JuniorChat<{ slack: SlackAdapter }> { - initializeProductionApp(); - return productionBot as JuniorChat<{ slack: SlackAdapter }>; + registerProductionHandlers( + bot, + { + github: githubRuntime, + slack: slackRuntime, + }, + { + slackPlatformConfig: platformConfigs.slack, + }, + ); + productionBot = bot; + return bot; + }; } diff --git a/packages/junior/src/chat/capabilities/factory.ts b/packages/junior/src/chat/capabilities/factory.ts index b7f17182..14aa6034 100644 --- a/packages/junior/src/chat/capabilities/factory.ts +++ b/packages/junior/src/chat/capabilities/factory.ts @@ -109,7 +109,7 @@ function getSandboxEgressRouter(): ProviderCredentialRouter { /** Issue one provider credential lease for host-side sandbox egress proxying. */ export async function issueProviderCredentialLease(input: { provider: string; - requesterId: string; + requesterId?: string; reason: string; }): Promise { return await getSandboxEgressRouter().issue(input); diff --git a/packages/junior/src/chat/config.ts b/packages/junior/src/chat/config.ts index 3810c549..eb2bd872 100644 --- a/packages/junior/src/chat/config.ts +++ b/packages/junior/src/chat/config.ts @@ -52,6 +52,13 @@ export interface AdvisorConfig { export interface ChatConfig { bot: BotConfig; functionMaxDurationSeconds: number; + github: { + appId?: string; + appPrivateKey?: string; + botUsername?: string; + installationId?: number; + webhookSecret?: string; + }; slack: { botToken?: string; clientId?: string; @@ -137,6 +144,23 @@ function parseAdvisorThinkingLevel( ); } +function parseOptionalPositiveInteger( + rawValue: string | undefined, + envName: string, +): number | undefined { + const trimmed = toOptionalTrimmed(rawValue); + if (!trimmed) { + return undefined; + } + + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error(`${envName} must be a positive integer`); + } + + return parsed; +} + // Compile-time assertion: `getModel`'s second generic is constrained to // `keyof (typeof MODELS)[TProvider]`, so a stale default becomes a tsc error. const DEFAULT_MODEL_ID = getModel("vercel-ai-gateway", "openai/gpt-5.4").id; @@ -191,6 +215,16 @@ export function readChatConfig( return { bot: readBotConfig(env), functionMaxDurationSeconds: resolveFunctionMaxDurationSeconds(env), + github: { + appId: toOptionalTrimmed(env.GITHUB_APP_ID), + appPrivateKey: toOptionalTrimmed(env.GITHUB_APP_PRIVATE_KEY), + installationId: parseOptionalPositiveInteger( + env.GITHUB_INSTALLATION_ID, + "GITHUB_INSTALLATION_ID", + ), + webhookSecret: toOptionalTrimmed(env.GITHUB_WEBHOOK_SECRET), + botUsername: toOptionalTrimmed(env.GITHUB_BOT_USERNAME), + }, slack: { botToken: toOptionalTrimmed(env.SLACK_BOT_TOKEN) ?? @@ -236,6 +270,26 @@ export function getSlackClientSecret(): string | undefined { return chatConfig.slack.clientSecret; } +export function getGitHubAppId(): string | undefined { + return chatConfig.github.appId; +} + +export function getGitHubAppPrivateKey(): string | undefined { + return chatConfig.github.appPrivateKey; +} + +export function getGitHubInstallationId(): number | undefined { + return chatConfig.github.installationId; +} + +export function getGitHubWebhookSecret(): string | undefined { + return chatConfig.github.webhookSecret; +} + +export function getGitHubBotUsername(): string | undefined { + return chatConfig.github.botUsername; +} + export function hasRedisConfig(): boolean { return Boolean(chatConfig.state.redisUrl); } diff --git a/packages/junior/src/chat/github/adapter.ts b/packages/junior/src/chat/github/adapter.ts new file mode 100644 index 00000000..8355fd3c --- /dev/null +++ b/packages/junior/src/chat/github/adapter.ts @@ -0,0 +1,33 @@ +import { + createGitHubAdapter, + type GitHubAdapter, + type GitHubAdapterConfig, +} from "@chat-adapter/github"; + +export interface JuniorGitHubAdapterConfig { + appId: string; + installationId: number; + logger?: GitHubAdapterConfig["logger"]; + privateKey: string; + userName: string; + webhookSecret: string; +} + +/** + * Create the repository's GitHub adapter. + * + * Junior maps repository env names into explicit adapter options so runtime + * wiring stays stable if adapter-level env defaults change. + */ +export function createJuniorGitHubAdapter( + config: JuniorGitHubAdapterConfig, +): GitHubAdapter { + return createGitHubAdapter({ + appId: config.appId, + privateKey: config.privateKey, + installationId: config.installationId, + webhookSecret: config.webhookSecret, + userName: config.userName, + ...(config.logger ? { logger: config.logger } : {}), + }); +} diff --git a/packages/junior/src/chat/github/mention.ts b/packages/junior/src/chat/github/mention.ts new file mode 100644 index 00000000..fb413c0b --- /dev/null +++ b/packages/junior/src/chat/github/mention.ts @@ -0,0 +1,17 @@ +/** Normalize the configured GitHub mention handle used for trigger matching. */ +export function normalizeGitHubMentionTarget(userName: string): string { + return userName + .trim() + .replace(/^@/, "") + .replace(/\[bot\]$/i, ""); +} + +/** Return the mention handles a GitHub App user may appear as in comments. */ +export function getGitHubMentionTargets(userName: string): string[] { + const configuredTarget = userName.trim().replace(/^@/, ""); + const normalizedTarget = normalizeGitHubMentionTarget(userName); + const appTarget = normalizedTarget ? `${normalizedTarget}[bot]` : ""; + return [ + ...new Set([configuredTarget, normalizedTarget, appTarget].filter(Boolean)), + ]; +} diff --git a/packages/junior/src/chat/github/reply-executor.ts b/packages/junior/src/chat/github/reply-executor.ts new file mode 100644 index 00000000..cf8321df --- /dev/null +++ b/packages/junior/src/chat/github/reply-executor.ts @@ -0,0 +1,361 @@ +import type { Message, Thread } from "chat"; +import { botConfig, getGitHubBotUsername } from "@/chat/config"; +import { + buildTurnFailureResponse, + logException, + setSpanAttributes, + setTags, + withSpan, +} from "@/chat/logging"; +import { GITHUB_COMMENT_SURFACE } from "@/chat/surface"; +import { getGitHubMentionTargets } from "@/chat/github/mention"; +import type { PlatformRuntimeConfig } from "@/chat/platform-config"; +import { applyPendingAuthUpdate } from "@/chat/services/pending-auth"; +import { + finalizeFailedTurnReply, + getAgentTurnDiagnosticsAttributes, +} from "@/chat/services/turn-failure-response"; +import { + generateConversationId, + markConversationMessage, + normalizeConversationText, + updateConversationStats, + upsertConversationMessage, +} from "@/chat/services/conversation-memory"; +import { type PreparedTurnState } from "@/chat/runtime/turn-preparation"; +import { + buildDeterministicTurnId, + isRetryableTurnError, + markTurnCompleted, + markTurnFailed, + startActiveTurn, +} from "@/chat/runtime/turn"; +import { completeAuthPauseTurn } from "@/chat/runtime/auth-pause-state"; +import { + getRunId, + getThreadId, + stripLeadingBotMention, +} from "@/chat/runtime/thread-context"; +import { + mergeArtifactsState, + persistThreadState, +} from "@/chat/runtime/thread-state"; +import { generateAssistantReply as generateAssistantReplyImpl } from "@/chat/respond"; + +export interface GitHubReplyExecutorServices { + generateAssistantReply: typeof generateAssistantReplyImpl; +} + +interface GitHubReplyExecutorDeps { + platformConfig?: PlatformRuntimeConfig; + prepareTurnState: (args: { + explicitMention: boolean; + message: Message; + thread: Thread; + userText: string; + context: { + threadId?: string; + requesterId?: string; + channelId?: string; + runId?: string; + }; + }) => Promise; + services: GitHubReplyExecutorServices; +} + +function buildAuthUnavailableResponse(provider: string | undefined): string { + const providerText = provider ? `${provider} ` : ""; + return [ + `I can't complete this from GitHub yet because it requires interactive ${providerText}authorization.`, + "Please retry from Slack after connecting the required account.", + ].join(" "); +} + +export function createReplyToGitHubThread(deps: GitHubReplyExecutorDeps) { + return async function replyToGitHubThread( + thread: Thread, + message: Message, + options: { + beforeFirstResponsePost?: () => Promise; + explicitMention?: boolean; + preparedState?: PreparedTurnState; + } = {}, + ): Promise { + if (message.author.isMe) { + return; + } + + const threadId = getThreadId(thread, message); + const runId = getRunId(thread, message); + const conversationId = threadId ?? runId; + + await withSpan( + "chat.reply", + "chat.reply", + { + conversationId, + runId, + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + }, + async () => { + const strippedUserText = getGitHubMentionTargets( + getGitHubBotUsername() ?? botConfig.userName, + ).reduce( + (next, botUserName) => + stripLeadingBotMention(next, { + botUserName, + stripLeadingSlackMentionToken: false, + }), + message.text, + ); + const userText = strippedUserText || message.text; + const preparedState = + options.preparedState ?? + (await deps.prepareTurnState({ + thread, + message, + userText, + explicitMention: Boolean( + options.explicitMention ?? message.isMention ?? true, + ), + context: { + threadId, + requesterId: message.author.userId, + channelId: undefined, + runId, + }, + })); + const turnId = buildDeterministicTurnId(message.id); + startActiveTurn({ + conversation: preparedState.conversation, + nextTurnId: turnId, + updateConversationStats, + }); + setTags({ + conversationId, + }); + await persistThreadState(thread, { + conversation: preparedState.conversation, + }); + + let beforeFirstResponsePostCalled = false; + const beforeFirstResponsePost = async (): Promise => { + if (beforeFirstResponsePostCalled) { + return; + } + beforeFirstResponsePostCalled = true; + await options.beforeFirstResponsePost?.(); + }; + let persistedAtLeastOnce = false; + let shouldPersistFailureState = true; + + try { + let reply = await deps.services.generateAssistantReply(userText, { + surface: GITHUB_COMMENT_SURFACE, + requester: { + userId: message.author.userId, + userName: message.author.userName, + fullName: message.author.fullName, + }, + conversationContext: + preparedState.routingContext ?? preparedState.conversationContext, + artifactState: preparedState.artifacts, + piMessages: preparedState.conversation.piMessages, + pendingAuth: preparedState.conversation.processing.pendingAuth, + configuration: preparedState.configuration, + platformConfig: deps.platformConfig, + channelConfiguration: preparedState.channelConfiguration, + inboundAttachmentCount: message.attachments.length, + correlation: { + conversationId, + threadId, + turnId, + runId, + requesterId: message.author.userId, + }, + sandbox: { + sandboxId: preparedState.sandboxId, + sandboxDependencyProfileHash: + preparedState.sandboxDependencyProfileHash, + }, + onSandboxAcquired: async (sandbox) => { + await persistThreadState(thread, { + sandboxId: sandbox.sandboxId, + sandboxDependencyProfileHash: + sandbox.sandboxDependencyProfileHash, + }); + }, + onArtifactStateUpdated: async (artifacts) => { + await persistThreadState(thread, { artifacts }); + }, + onAuthPending: async (pendingAuth) => { + await applyPendingAuthUpdate({ + conversation: preparedState.conversation, + conversationId, + nextPendingAuth: pendingAuth, + }); + await persistThreadState(thread, { + conversation: preparedState.conversation, + }); + }, + }); + + setSpanAttributes(getAgentTurnDiagnosticsAttributes(reply)); + if (reply.diagnostics.outcome !== "success") { + reply = finalizeFailedTurnReply({ + reply, + logException, + context: { + conversationId, + runId, + assistantUserName: botConfig.userName, + modelId: reply.diagnostics.modelId, + }, + attributes: { + "app.surface.platform": "github", + }, + }); + } + + markConversationMessage( + preparedState.conversation, + preparedState.userMessageId, + { + replied: true, + skippedReason: undefined, + }, + ); + upsertConversationMessage(preparedState.conversation, { + id: generateConversationId("assistant"), + role: "assistant", + text: normalizeConversationText(reply.text) || "[empty response]", + createdAtMs: Date.now(), + author: { + userName: botConfig.userName, + isBot: true, + }, + meta: { + replied: true, + }, + }); + if (reply.piMessages) { + preparedState.conversation.piMessages = reply.piMessages; + } + + const deliveryText = + normalizeConversationText(reply.text) || + "I couldn't produce a text response for this turn."; + + await beforeFirstResponsePost(); + await thread.post(deliveryText); + + const artifactStatePatch = reply.artifactStatePatch + ? { ...reply.artifactStatePatch } + : {}; + const nextArtifacts = + Object.keys(artifactStatePatch).length > 0 + ? mergeArtifactsState(preparedState.artifacts, artifactStatePatch) + : undefined; + markTurnCompleted({ + conversation: preparedState.conversation, + nowMs: Date.now(), + updateConversationStats, + }); + await persistThreadState(thread, { + artifacts: nextArtifacts, + conversation: preparedState.conversation, + sandboxId: reply.sandboxId, + sandboxDependencyProfileHash: reply.sandboxDependencyProfileHash, + }); + persistedAtLeastOnce = true; + } catch (error) { + if ( + isRetryableTurnError(error, "mcp_auth_resume") || + isRetryableTurnError(error, "plugin_auth_resume") + ) { + const authUnavailableText = buildAuthUnavailableResponse( + error.metadata?.authProvider, + ); + completeAuthPauseTurn({ + conversation: preparedState.conversation, + sessionId: error.metadata?.sessionId ?? turnId, + }); + upsertConversationMessage(preparedState.conversation, { + id: generateConversationId("assistant"), + role: "assistant", + text: authUnavailableText, + createdAtMs: Date.now(), + author: { + userName: botConfig.userName, + isBot: true, + }, + meta: { + replied: true, + }, + }); + await beforeFirstResponsePost(); + await thread.post(authUnavailableText); + await persistThreadState(thread, { + conversation: preparedState.conversation, + }); + persistedAtLeastOnce = true; + shouldPersistFailureState = false; + return; + } + + const eventId = logException( + error, + "github_turn_reply_failed", + { + conversationId, + runId, + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + }, + { + "app.surface.platform": "github", + }, + "GitHub turn reply failed", + ); + await beforeFirstResponsePost(); + try { + await thread.post(buildTurnFailureResponse(eventId ?? "unknown")); + } catch (fallbackError) { + logException( + fallbackError, + "github_turn_failure_reply_post_failed", + { + conversationId, + runId, + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + }, + { + "app.error.original_event_id": eventId, + "app.surface.platform": "github", + }, + "Failed to post GitHub fallback reply", + ); + throw fallbackError; + } + } finally { + if (!persistedAtLeastOnce && shouldPersistFailureState) { + markTurnFailed({ + conversation: preparedState.conversation, + nowMs: Date.now(), + userMessageId: preparedState.userMessageId, + markConversationMessage: (conversation, messageId, patch) => { + markConversationMessage(conversation, messageId, patch); + }, + updateConversationStats, + }); + await persistThreadState(thread, { + conversation: preparedState.conversation, + }); + } + } + }, + ); + }; +} diff --git a/packages/junior/src/chat/github/runtime.ts b/packages/junior/src/chat/github/runtime.ts new file mode 100644 index 00000000..5aa43873 --- /dev/null +++ b/packages/junior/src/chat/github/runtime.ts @@ -0,0 +1,112 @@ +import type { Message, Thread } from "chat"; +import { isRetryableTurnError } from "@/chat/runtime/turn"; + +export interface GitHubReplyHooks { + beforeFirstResponsePost?: () => Promise; +} + +export interface GitHubTurnRuntime { + handleNewMention: ( + thread: Thread, + message: Message, + hooks?: GitHubReplyHooks, + ) => Promise; +} + +export interface GitHubTurnRuntimeDependencies { + assistantUserName: string; + getRunId: (thread: Thread, message: Message) => string | undefined; + getThreadId: (thread: Thread, message: Message) => string | undefined; + logException: ( + error: unknown, + eventName: string, + context?: Record, + attributes?: Record, + body?: string, + ) => string | undefined; + modelId: string; + replyToThread: ( + thread: Thread, + message: Message, + options?: { + beforeFirstResponsePost?: () => Promise; + explicitMention?: boolean; + }, + ) => Promise; + withSpan: ( + name: string, + op: string, + context: Record, + callback: () => Promise, + ) => Promise; +} + +export function createGitHubTurnRuntime( + deps: GitHubTurnRuntimeDependencies, +): GitHubTurnRuntime { + return { + async handleNewMention( + thread: Thread, + message: Message, + hooks?: GitHubReplyHooks, + ): Promise { + const threadId = deps.getThreadId(thread, message); + const runId = deps.getRunId(thread, message); + const conversationId = threadId ?? runId; + try { + await deps.withSpan( + "chat.turn", + "chat.turn", + { + conversationId, + runId, + assistantUserName: deps.assistantUserName, + modelId: deps.modelId, + }, + async () => { + await deps.replyToThread(thread, message, { + explicitMention: true, + beforeFirstResponsePost: hooks?.beforeFirstResponsePost, + }); + }, + ); + } catch (error) { + if ( + isRetryableTurnError(error, "mcp_auth_resume") || + isRetryableTurnError(error, "plugin_auth_resume") + ) { + deps.logException( + error, + "github_mention_handler_auth_pause", + { + conversationId, + runId, + assistantUserName: deps.assistantUserName, + modelId: deps.modelId, + }, + { + "app.ai.retryable_reason": error.reason, + "app.surface.platform": "github", + }, + "GitHub mention handler parked turn for auth resume", + ); + return; + } + deps.logException( + error, + "github_mention_handler_failed", + { + conversationId, + runId, + assistantUserName: deps.assistantUserName, + modelId: deps.modelId, + }, + { + "app.surface.platform": "github", + }, + "GitHub mention handler failed", + ); + } + }, + }; +} diff --git a/packages/junior/src/chat/platform-config.ts b/packages/junior/src/chat/platform-config.ts new file mode 100644 index 00000000..795707e4 --- /dev/null +++ b/packages/junior/src/chat/platform-config.ts @@ -0,0 +1,175 @@ +import { + resolveEnabledChatPlatforms, + SUPPORTED_CHAT_PLATFORMS, + type ChatPlatform, +} from "@/chat/platforms"; +import { getPluginProviders } from "@/chat/plugins/registry"; +import { discoverSkills } from "@/chat/skills"; + +export interface JuniorPlatformOptions { + plugins: readonly string[]; + skills?: readonly string[]; + configDefaults?: Record; +} + +export type JuniorPlatformOptionsMap = Partial< + Record +>; + +export interface PlatformRuntimeConfig { + pluginNames?: readonly string[]; + skillNames?: readonly string[]; + configDefaults?: Record; +} + +export type PlatformRuntimeConfigMap = Partial< + Record +>; + +interface ResolvedPlatformConfig { + enabledPlatforms: ChatPlatform[]; + platformConfigs: PlatformRuntimeConfigMap; +} + +function normalizeNameList(input: readonly string[]): string[] { + const names = new Set(); + for (const raw of input) { + const name = raw.trim().toLowerCase(); + if (!name) { + continue; + } + names.add(name); + } + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function assertSamePlatforms( + left: readonly ChatPlatform[], + right: readonly ChatPlatform[], +): void { + const leftSorted = [...left].sort(); + const rightSorted = [...right].sort(); + if ( + leftSorted.length !== rightSorted.length || + leftSorted.some((platform, index) => platform !== rightSorted[index]) + ) { + throw new Error( + "enabledPlatforms must match platforms keys when platforms is configured", + ); + } +} + +/** Resolve platform enablement and per-platform runtime configuration. */ +export function resolvePlatformConfig(input: { + enabledPlatforms?: readonly string[]; + platforms?: JuniorPlatformOptionsMap; +}): ResolvedPlatformConfig { + if (!input.platforms) { + const enabledPlatforms = resolveEnabledChatPlatforms( + input.enabledPlatforms, + "enabledPlatforms", + ); + return { + enabledPlatforms, + platformConfigs: Object.fromEntries( + enabledPlatforms.map((platform) => [platform, {}]), + ), + }; + } + + const platformConfigs: PlatformRuntimeConfigMap = {}; + const enabledPlatforms: ChatPlatform[] = []; + + for (const rawPlatform of Object.keys(input.platforms).sort()) { + const platform = rawPlatform.trim().toLowerCase(); + if (!SUPPORTED_CHAT_PLATFORMS.includes(platform as ChatPlatform)) { + throw new Error( + `platforms must contain only: ${SUPPORTED_CHAT_PLATFORMS.join(", ")}`, + ); + } + + const config = (input.platforms as Record)[ + rawPlatform + ]; + if (!config || typeof config !== "object") { + throw new Error(`platforms.${platform} must be an object`); + } + if (!Array.isArray(config.plugins)) { + throw new Error( + `platforms.${platform}.plugins must be an array of plugin names`, + ); + } + if (config.skills !== undefined && !Array.isArray(config.skills)) { + throw new Error( + `platforms.${platform}.skills must be an array of skill names`, + ); + } + enabledPlatforms.push(platform as ChatPlatform); + platformConfigs[platform as ChatPlatform] = { + pluginNames: normalizeNameList(config.plugins), + ...(config.skills + ? { + skillNames: normalizeNameList(config.skills), + } + : {}), + ...(config.configDefaults + ? { configDefaults: { ...config.configDefaults } } + : {}), + }; + } + + if (enabledPlatforms.length === 0) { + throw new Error( + `platforms must contain at least one platform: ${SUPPORTED_CHAT_PLATFORMS.join(", ")}`, + ); + } + + if (input.enabledPlatforms) { + assertSamePlatforms( + resolveEnabledChatPlatforms(input.enabledPlatforms, "enabledPlatforms"), + enabledPlatforms, + ); + } + + return { enabledPlatforms, platformConfigs }; +} + +/** Validate configured platform plugin and skill names against installed content. */ +export async function validatePlatformConfig( + platformConfigs: PlatformRuntimeConfigMap, +): Promise { + const knownPlugins = new Set( + getPluginProviders().map((plugin) => plugin.manifest.name), + ); + const knownSkills = new Map( + (await discoverSkills()).map((skill) => [skill.name, skill]), + ); + + for (const platform of SUPPORTED_CHAT_PLATFORMS) { + const config = platformConfigs[platform]; + if (!config?.pluginNames) { + continue; + } + const pluginNames = new Set(config.pluginNames); + for (const pluginName of pluginNames) { + if (!knownPlugins.has(pluginName)) { + throw new Error( + `platforms.${platform}.plugins contains unknown plugin "${pluginName}"`, + ); + } + } + for (const skillName of config.skillNames ?? []) { + const skill = knownSkills.get(skillName); + if (!skill) { + throw new Error( + `platforms.${platform}.skills contains unknown skill "${skillName}"`, + ); + } + if (skill.pluginProvider && !pluginNames.has(skill.pluginProvider)) { + throw new Error( + `platforms.${platform}.skills includes "${skillName}" from plugin "${skill.pluginProvider}", but platforms.${platform}.plugins does not include "${skill.pluginProvider}"`, + ); + } + } + } +} diff --git a/packages/junior/src/chat/platforms.ts b/packages/junior/src/chat/platforms.ts new file mode 100644 index 00000000..bdc75737 --- /dev/null +++ b/packages/junior/src/chat/platforms.ts @@ -0,0 +1,37 @@ +export const SUPPORTED_CHAT_PLATFORMS = ["slack", "github"] as const; + +export type ChatPlatform = (typeof SUPPORTED_CHAT_PLATFORMS)[number]; + +export const DEFAULT_CHAT_PLATFORMS: ChatPlatform[] = ["slack"]; + +/** Validate and normalize the chat ingress platforms enabled for this app. */ +export function resolveEnabledChatPlatforms( + platforms: readonly string[] | undefined, + optionName = "enabledPlatforms", +): ChatPlatform[] { + if (platforms === undefined) { + return [...DEFAULT_CHAT_PLATFORMS]; + } + + const normalized = new Set(); + for (const rawPlatform of platforms) { + const platform = rawPlatform.trim().toLowerCase(); + if (!platform) { + continue; + } + if (!SUPPORTED_CHAT_PLATFORMS.includes(platform as ChatPlatform)) { + throw new Error( + `${optionName} must contain only: ${SUPPORTED_CHAT_PLATFORMS.join(", ")}`, + ); + } + normalized.add(platform as ChatPlatform); + } + + if (normalized.size === 0) { + throw new Error( + `${optionName} must contain at least one platform: ${SUPPORTED_CHAT_PLATFORMS.join(", ")}`, + ); + } + + return [...normalized]; +} diff --git a/packages/junior/src/chat/plugins/registry.ts b/packages/junior/src/chat/plugins/registry.ts index e395a037..b06769d1 100644 --- a/packages/junior/src/chat/plugins/registry.ts +++ b/packages/junior/src/chat/plugins/registry.ts @@ -297,6 +297,17 @@ function ensurePluginsLoaded(): LoadedPluginState { return state; } +function filterPluginsByName( + plugins: PluginDefinition[], + allowedNames?: readonly string[], +): PluginDefinition[] { + if (!allowedNames) { + return [...plugins]; + } + const allowed = new Set(allowedNames); + return plugins.filter((plugin) => allowed.has(plugin.manifest.name)); +} + // --- Sync exports --- /** Return the current plugin catalog signature used for cache invalidation. */ @@ -304,6 +315,7 @@ export function getPluginCatalogSignature(): string { return ensurePluginsLoaded().signature; } +/** Return plugin capability metadata for install-wide capability registration. */ export function getPluginCapabilityProviders(): CapabilityProviderDefinition[] { const state = ensurePluginsLoaded(); return state.pluginDefinitions.map((plugin) => ({ @@ -323,14 +335,24 @@ export function getPluginCapabilityProviders(): CapabilityProviderDefinition[] { })); } -export function getPluginProviders(): PluginDefinition[] { - return [...ensurePluginsLoaded().pluginDefinitions]; +/** Return installed plugin definitions, optionally constrained to platform-enabled providers. */ +export function getPluginProviders( + allowedNames?: readonly string[], +): PluginDefinition[] { + return filterPluginsByName( + ensurePluginsLoaded().pluginDefinitions, + allowedNames, + ); } -export function getPluginMcpProviders(): PluginDefinition[] { - return ensurePluginsLoaded().pluginDefinitions.filter((plugin) => - Boolean(plugin.manifest.mcp), - ); +/** Return installed MCP-capable plugins, optionally constrained to platform-enabled providers. */ +export function getPluginMcpProviders( + allowedNames?: readonly string[], +): PluginDefinition[] { + return filterPluginsByName( + ensurePluginsLoaded().pluginDefinitions, + allowedNames, + ).filter((plugin) => Boolean(plugin.manifest.mcp)); } export function getPluginRuntimeDependencies(): PluginRuntimeDependency[] { diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index 0b832f02..93eda356 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { botConfig, getRuntimeMetadata } from "@/chat/config"; +import { + botConfig, + getGitHubBotUsername, + getRuntimeMetadata, +} from "@/chat/config"; import { listReferenceFiles, soulPathCandidates, @@ -8,14 +12,17 @@ import { } from "@/chat/discovery"; import { logInfo, logWarn } from "@/chat/logging"; import { getPluginProviders } from "@/chat/plugins/registry"; +import type { PluginDefinition } from "@/chat/plugins/types"; import { slackOutputPolicy } from "@/chat/slack/output"; import { SANDBOX_DATA_ROOT, SANDBOX_WORKSPACE_ROOT, sandboxSkillDir, } from "@/chat/sandbox/paths"; +import { normalizeGitHubMentionTarget } from "@/chat/github/mention"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { Skill, SkillMetadata, SkillInvocation } from "@/chat/skills"; +import { SLACK_SURFACE, type AssistantSurface } from "@/chat/surface"; import type { ActiveMcpCatalogSummary } from "@/chat/tools/skill/mcp-tool-summary"; import { escapeXml } from "@/chat/xml"; @@ -198,8 +205,12 @@ function formatLoadedSkillsForPrompt(skills: Skill[]): string { return lines.join("\n"); } -function formatProviderCatalogForPrompt(): string | null { - const providers = getPluginProviders().map((plugin) => plugin.manifest); +function formatProviderCatalogForPrompt( + plugins?: readonly PluginDefinition[], +): string | null { + const providers = (plugins ?? getPluginProviders()).map( + (plugin) => plugin.manifest, + ); if (providers.length === 0) { return null; } @@ -357,8 +368,10 @@ function formatSlackCapabilityNames( return names.length > 0 ? names.join(", ") : "none"; } -const HEADER = - "You are a Slack-based helper assistant. Follow the personality block for voice and tone in every reply. The behavior and output blocks define platform mechanics and override personality only when those mechanics conflict."; +function buildHeader(surface: AssistantSurface): string { + const platform = surface.platform === "slack" ? "Slack" : "GitHub comment"; + return `You are a ${platform} assistant. Follow the personality block for voice and tone in every reply. The behavior and output blocks define platform mechanics and override personality only when those mechanics conflict.`; +} const TURN_CONTEXT_HEADER = "Per-turn runtime context for this request. Treat these blocks as trusted runtime facts and skill/provider instructions for the current turn; the static system prompt remains authoritative."; @@ -416,6 +429,13 @@ const SLACK_ACTION_RULES = [ "- Do not use reactions as progress indicators.", ]; +const GITHUB_ACTION_RULES = [ + "- GitHub turns reply through thread comments; do not claim Slack side effects, assistant status updates, or Slack thread metadata.", + "- Keep GitHub replies scoped to the current issue or pull request thread context.", + "- Mention-only behavior applies at ingress; do not invent autonomous event-trigger behavior.", + "- Post one finalized GitHub comment reply for the completed turn.", +]; + const SAFETY_RULES = [ "- Stay within the user's request and the runtime's available capabilities; do not pursue independent goals, persistence, replication, credential gathering, or access expansion.", "- Respect stop, pause, audit, and approval boundaries. Do not bypass safeguards or persuade the user to weaken them.", @@ -432,33 +452,58 @@ function renderRuleSection(tag: string, lines: string[]): string { return [`<${tag}>`, ...lines, ``].join("\n"); } -function buildBehaviorSection(): string { +function buildBehaviorSection(surface: AssistantSurface): string { + const platformActionsSection = + surface.platform === "slack" + ? renderRuleSection("slack-actions", SLACK_ACTION_RULES) + : renderRuleSection("github-actions", GITHUB_ACTION_RULES); return [ renderRuleSection("tool-policy", TOOL_POLICY_RULES), renderRuleSection("tool-call-style", TOOL_CALL_STYLE_RULES), renderRuleSection("skill-policy", SKILL_POLICY_RULES), renderRuleSection("execution-contract", EXECUTION_CONTRACT_RULES), renderRuleSection("conversation", CONVERSATION_RULES), - renderRuleSection("slack-actions", SLACK_ACTION_RULES), + platformActionsSection, renderRuleSection("safety", SAFETY_RULES), renderRuleSection("failure-handling", FAILURE_RULES), ].join("\n\n"); } -function buildOutputSection(): string { - const openTag = ``; +function buildOutputSection(surface: AssistantSurface): string { + if (surface.platform === "slack") { + const openTag = ``; + return [ + openTag, + "- Start with the answer or result, not internal process narration.", + "- Use Slack-flavored Markdown: **bold** section labels, `code`, [text](url) links, bullet lists, and fenced code blocks. No tables. When the answer primarily lists several URLs, show each URL bare instead of as a labeled link.", + "- Keep replies brief and scannable; use bullets or short code blocks when helpful, and one compact thread reply when it fits.", + "- When a research or document-style answer would benefit from continuation, multiple sections, or future reference value, create a Slack canvas and keep the thread reply to one or two short sentences plus the link; do not recap the canvas contents.", + "- Unless a successful Slack side-effect tool intentionally satisfied the request by itself, end every turn with a final user-facing markdown response.", + "", + ].join("\n"); + } + return [ - openTag, + '', "- Start with the answer or result, not internal process narration.", - "- Use Slack-flavored Markdown: **bold** section labels, `code`, [text](url) links, bullet lists, and fenced code blocks. No tables. When the answer primarily lists several URLs, show each URL bare instead of as a labeled link.", - "- Keep replies brief and scannable; use bullets or short code blocks when helpful, and one compact thread reply when it fits.", - "- When a research or document-style answer would benefit from continuation, multiple sections, or future reference value, create a Slack canvas and keep the thread reply to one or two short sentences plus the link; do not recap the canvas contents.", - "- Unless a successful Slack side-effect tool intentionally satisfied the request by itself, end every turn with a final user-facing markdown response.", + "- Use GitHub-flavored Markdown suitable for issue and pull-request comments: concise sections, bullets, links, and fenced code blocks when helpful.", + "- Keep replies brief and review-friendly; do not reference Slack-specific surfaces (canvas, channel posts, reactions, assistant status).", + "- End the turn with one finalized GitHub comment reply.", "", ].join("\n"); } -function buildIdentitySection(): string { +function buildIdentitySection(surface: AssistantSurface): string { + if (surface.platform === "github") { + const mentionTarget = normalizeGitHubMentionTarget( + getGitHubBotUsername() ?? botConfig.userName, + ); + return renderTagBlock( + "identity", + `Your GitHub mention target is \`@${escapeXml(mentionTarget)}\`.`, + ); + } + return renderTagBlock( "identity", `Your Slack username is \`${escapeXml(botConfig.userName)}\`.`, @@ -469,6 +514,7 @@ function buildRuntimeSection(params: { channelId?: string; fastModelId?: string; modelId?: string; + surface?: AssistantSurface; slackCapabilities?: { canAddReactions?: boolean; canCreateCanvas?: boolean; @@ -476,6 +522,8 @@ function buildRuntimeSection(params: { }; thinkingLevel?: string; }): string { + const surface = params.surface ?? SLACK_SURFACE; + const isSlackSurface = surface.platform === "slack"; const lines = [ `- version: ${escapeXml(getRuntimeMetadata().version ?? "unknown")}`, params.modelId ? `- model: ${escapeXml(params.modelId)}` : "", @@ -483,8 +531,11 @@ function buildRuntimeSection(params: { params.thinkingLevel ? `- thinking: ${escapeXml(params.thinkingLevel)}` : "", - params.channelId ? "- channel: slack" : "", - params.channelId + `- platform: ${escapeXml(surface.platform)}`, + `- output_format: ${escapeXml(surface.outputFormat)}`, + `- tool_profile: ${escapeXml(surface.toolProfile)}`, + isSlackSurface && params.channelId ? "- channel: slack" : "", + isSlackSurface && params.channelId ? `- slack_capabilities: ${escapeXml( formatSlackCapabilityNames(params.slackCapabilities), )}` @@ -563,6 +614,7 @@ function buildCapabilitiesSection(params: { availableSkills: SkillMetadata[]; activeSkills: Skill[]; activeMcpCatalogs: ActiveMcpCatalogSummary[]; + pluginProviders?: readonly PluginDefinition[]; toolGuidance?: ToolPromptContext[]; }): string { const blocks: string[] = []; @@ -581,7 +633,9 @@ function buildCapabilitiesSection(params: { blocks.push(renderTagBlock("tool-guidance", toolGuidance)); } - const providerCatalog = formatProviderCatalogForPrompt(); + const providerCatalog = formatProviderCatalogForPrompt( + params.pluginProviders, + ); if (providerCatalog) { blocks.push(renderTagBlock("providers", providerCatalog)); } @@ -593,11 +647,13 @@ type TurnContextPromptInput = { availableSkills: SkillMetadata[]; activeSkills: Skill[]; activeMcpCatalogs?: ActiveMcpCatalogSummary[]; + pluginProviders?: readonly PluginDefinition[]; toolGuidance?: ToolPromptContext[]; runtime?: { channelId?: string; fastModelId?: string; modelId?: string; + surface?: AssistantSurface; slackCapabilities?: { canAddReactions?: boolean; canCreateCanvas?: boolean; @@ -621,17 +677,28 @@ type TurnContextPromptInput = { turnState?: "fresh" | "resumed"; }; -const STATIC_SYSTEM_PROMPT = [ - HEADER, - buildIdentitySection(), - renderTagBlock("personality", JUNIOR_PERSONALITY.trim()), - renderTagBlock("behavior", buildBehaviorSection()), - buildOutputSection(), -].join("\n\n"); - -/** Return byte-stable platform instructions shared by every conversation and turn. */ -export function buildSystemPrompt(): string { - return STATIC_SYSTEM_PROMPT; +const systemPromptCache = new Map(); + +/** Return platform instructions shared by every conversation and turn. */ +export function buildSystemPrompt( + params: { surface?: AssistantSurface } = {}, +): string { + const surface = params.surface ?? SLACK_SURFACE; + const cacheKey = JSON.stringify(surface); + const cached = systemPromptCache.get(cacheKey); + if (cached) { + return cached; + } + + const prompt = [ + buildHeader(surface), + buildIdentitySection(surface), + renderTagBlock("personality", JUNIOR_PERSONALITY.trim()), + renderTagBlock("behavior", buildBehaviorSection(surface)), + buildOutputSection(surface), + ].join("\n\n"); + systemPromptCache.set(cacheKey, prompt); + return prompt; } /** Build volatile runtime context that belongs in the user turn, not the system prompt. */ @@ -650,6 +717,7 @@ export function buildTurnContextPrompt(params: TurnContextPromptInput): string { availableSkills: params.availableSkills, activeSkills: params.activeSkills, activeMcpCatalogs: params.activeMcpCatalogs ?? [], + pluginProviders: params.pluginProviders, toolGuidance: params.toolGuidance ?? [], }), buildContextSection({ diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index 77603a76..d32a0e35 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -31,11 +31,12 @@ import { getPluginMcpProviders, getPluginProviders, } from "@/chat/plugins/registry"; +import type { PlatformRuntimeConfig } from "@/chat/platform-config"; import { McpToolManager } from "@/chat/mcp/tool-manager"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { ConversationPendingAuthState } from "@/chat/state/conversation"; import { createTools } from "@/chat/tools"; -import { resolveChannelCapabilities } from "@/chat/tools/channel-capabilities"; +import { resolveChannelCapabilities } from "@/chat/slack/tools/channel-capabilities"; import type { ToolDefinition } from "@/chat/tools/definition"; import { toActiveMcpCatalogSummaries } from "@/chat/tools/skill/mcp-tool-summary"; import type { ImageGenerateToolDeps } from "@/chat/tools/types"; @@ -67,6 +68,7 @@ import { toObservablePromptPart, upsertActiveSkill, } from "@/chat/respond-helpers"; +import { SLACK_SURFACE, type AssistantSurface } from "@/chat/surface"; import { buildTurnResult, type AssistantReply, @@ -113,6 +115,7 @@ export interface ReplyRequestContext { artifactState?: ThreadArtifactsState; pendingAuth?: ConversationPendingAuthState; configuration?: Record; + platformConfig?: PlatformRuntimeConfig; /** Durable Pi transcript for this conversation, excluding ephemeral turn context. */ piMessages?: PiMessage[]; channelConfiguration?: ChannelConfigurationService; @@ -145,9 +148,10 @@ export interface ReplyRequestContext { toolName: string; params: Record; }) => void; + surface?: AssistantSurface; } -let startupDiscoveryLogged = false; +const startupDiscoveryLoggedKeys = new Set(); const MAX_ROUTER_ATTACHMENT_PREVIEW_CHARS = 2_000; type UserTurnContentPart = @@ -169,6 +173,18 @@ function buildOmittedImageAttachmentNotice(count: number): string { ].join("\n"); } +function startupDiscoveryLogKey(args: { + platform: string; + pluginNames?: readonly string[]; + skillNames?: readonly string[]; +}): string { + return JSON.stringify({ + platform: args.platform, + pluginNames: args.pluginNames ?? null, + skillNames: args.skillNames ?? null, + }); +} + function trimRouterAttachmentText(text: string): string { const normalized = text.replaceAll("\0", " ").trim(); if (!normalized) { @@ -373,6 +389,8 @@ export async function generateAssistantReply( messageText: string, context: ReplyRequestContext = {}, ): Promise { + const surface = context.surface ?? SLACK_SURFACE; + const canResumePausedTurn = surface.platform === "slack"; const replyStartedAtMs = Date.now(); let timeoutResumeConversationId: string | undefined; let timeoutResumeSessionId: string | undefined; @@ -417,12 +435,21 @@ export async function generateAssistantReply( }; // ── Skill discovery ────────────────────────────────────────────── + const platformPluginNames = context.platformConfig?.pluginNames; + const platformSkillNames = context.platformConfig?.skillNames; + const pluginProviders = getPluginProviders(platformPluginNames); const availableSkills = await discoverSkills({ additionalRoots: context.skillDirs, + allowedPluginNames: platformPluginNames, + allowedSkillNames: platformSkillNames, + }); + const discoveryLogKey = startupDiscoveryLogKey({ + platform: surface.platform, + pluginNames: platformPluginNames, + skillNames: platformSkillNames, }); - if (!startupDiscoveryLogged) { - startupDiscoveryLogged = true; - const plugins = getPluginProviders(); + if (!startupDiscoveryLoggedKeys.has(discoveryLogKey)) { + startupDiscoveryLoggedKeys.add(discoveryLogKey); const roots = [ ...new Set(availableSkills.map((skill) => skill.skillPath)), ].sort(); @@ -433,8 +460,8 @@ export async function generateAssistantReply( "app.skill.count": availableSkills.length, "app.skill.names": availableSkills.map((skill) => skill.name).sort(), "app.file.directories": roots, - "app.plugin.count": plugins.length, - "app.plugin.names": plugins + "app.plugin.count": pluginProviders.length, + "app.plugin.names": pluginProviders .map((plugin) => plugin.manifest.name) .sort(), }, @@ -487,22 +514,30 @@ export async function generateAssistantReply( : {}; configurationValues = { ...getConfigDefaults(), + ...(context.platformConfig?.configDefaults ?? {}), ...(context.configuration ?? {}), ...persistedConfigurationValues, }; // ── Sandbox ────────────────────────────────────────────────────── - const requesterId = context.requester?.userId; + // Interactive auth links are Slack-user scoped today. GitHub turns may use + // host credentials, but must not reuse GitHub IDs as Slack user-token keys. + const authRequesterId = canResumePausedTurn + ? context.requester?.userId + : undefined; + const credentialEgress = + authRequesterId || surface.platform === "github" + ? { + requesterId: authRequesterId, + providerNames: platformPluginNames, + } + : undefined; const userTokenStore = createUserTokenStore(); sandboxExecutor = createSandboxExecutor({ sandboxId: context.sandbox?.sandboxId, sandboxDependencyProfileHash: context.sandbox?.sandboxDependencyProfileHash, traceContext: spanContext, - credentialEgress: requesterId - ? { - requesterId, - } - : undefined, + credentialEgress, onSandboxAcquired: async (sandbox) => { lastKnownSandboxId = sandbox.sandboxId; lastKnownSandboxDependencyProfileHash = @@ -513,7 +548,7 @@ export async function generateAssistantReply( const result = await maybeExecuteJrRpcCustomCommand(command, { activeSkill: skillSandbox.getActiveSkill(), channelConfiguration: context.channelConfiguration, - requesterId: context.requester?.userId, + requesterId: authRequesterId, onConfigurationValueChanged: (key, value) => { if (value === undefined) { delete configurationValues[key]; @@ -664,7 +699,7 @@ export async function generateAssistantReply( { conversationId: sessionConversationId, sessionId, - requesterId: context.requester?.userId, + requesterId: authRequesterId, channelId: context.correlation?.channelId, threadTs: context.correlation?.threadTs, toolChannelId: context.toolChannelId, @@ -682,7 +717,8 @@ export async function generateAssistantReply( { conversationId: sessionConversationId, sessionId, - requesterId: context.requester?.userId, + requesterId: authRequesterId, + providerNames: platformPluginNames, channelId: context.correlation?.channelId, threadTs: context.correlation?.threadTs, userMessage: userInput, @@ -694,10 +730,13 @@ export async function generateAssistantReply( () => agent?.abort(), ); - mcpToolManager = new McpToolManager(getPluginMcpProviders(), { - authProviderFactory: mcpAuth.authProviderFactory, - onAuthorizationRequired: mcpAuth.onAuthorizationRequired, - }); + mcpToolManager = new McpToolManager( + getPluginMcpProviders(platformPluginNames), + { + authProviderFactory: mcpAuth.authProviderFactory, + onAuthorizationRequired: mcpAuth.onAuthorizationRequired, + }, + ); const turnMcpToolManager = mcpToolManager; const getPendingAuthPause = () => pluginAuth.getPendingPause() ?? mcpAuth.getPendingPause(); @@ -717,7 +756,14 @@ export async function generateAssistantReply( // ── Tool creation ──────────────────────────────────────────────── const toolChannelId = context.toolChannelId ?? context.correlation?.channelId; - const channelCapabilities = resolveChannelCapabilities(toolChannelId); + const channelCapabilities = + surface.toolProfile === "slack" + ? resolveChannelCapabilities(toolChannelId) + : { + canCreateCanvas: false, + canPostToChannel: false, + canAddReactions: false, + }; const tools = createTools( availableSkills, { @@ -776,6 +822,7 @@ export async function generateAssistantReply( { channelId: toolChannelId, channelCapabilities, + toolProfile: surface.toolProfile, messageTs: context.correlation?.messageTs, threadTs: context.correlation?.threadTs, userText: userInput, @@ -816,17 +863,20 @@ export async function generateAssistantReply( const activeMcpCatalogs = toActiveMcpCatalogSummaries( turnMcpToolManager.getActiveToolCatalog(activeSkills), ); - baseInstructions = buildSystemPrompt(); + baseInstructions = buildSystemPrompt({ surface }); const turnContextPrompt = buildTurnContextPrompt({ availableSkills, activeSkills, activeMcpCatalogs, + pluginProviders, toolGuidance, runtime: { channelId: toolChannelId, fastModelId: botConfig.fastModelId, modelId: botConfig.modelId, - slackCapabilities: channelCapabilities, + slackCapabilities: + surface.platform === "slack" ? channelCapabilities : undefined, + surface, thinkingLevel: thinkingSelection.thinkingLevel, }, invocation: skillInvocation, @@ -1102,7 +1152,12 @@ export async function generateAssistantReply( assistantUserName: botConfig.userName, }); } catch (error) { - if (timedOut && timeoutResumeConversationId && timeoutResumeSessionId) { + if ( + canResumePausedTurn && + timedOut && + timeoutResumeConversationId && + timeoutResumeSessionId + ) { const checkpoint = await persistTimeoutCheckpoint({ conversationId: timeoutResumeConversationId, sessionId: timeoutResumeSessionId, @@ -1133,8 +1188,9 @@ export async function generateAssistantReply( } } - // ── MCP auth pause → checkpoint and retry ──────────────────────── + // ── Auth pause → checkpoint and retry ──────────────────────────── if ( + canResumePausedTurn && error instanceof AuthorizationPauseError && timeoutResumeConversationId && timeoutResumeSessionId @@ -1192,6 +1248,49 @@ export async function generateAssistantReply( throw error; } + if (error instanceof AuthorizationPauseError) { + logException( + error, + "assistant_reply_auth_pause_unsupported", + { + slackThreadId: context.correlation?.threadId, + slackUserId: context.correlation?.requesterId, + slackChannelId: context.correlation?.channelId, + runId: context.correlation?.runId, + assistantUserName: botConfig.userName, + modelId: botConfig.modelId, + }, + { + "app.auth.kind": error.kind, + "app.credential.provider": error.provider, + "app.surface.platform": surface.platform, + }, + "Authorization pause reached a surface without resume support", + ); + const authUnavailableText = [ + `I can't complete this from ${surface.platform} yet because it requires interactive ${error.provider} authorization.`, + "Please retry from Slack after connecting that account.", + ].join(" "); + return { + text: authUnavailableText, + ...getSandboxMetadata(), + diagnostics: { + outcome: "provider_error", + modelId: botConfig.modelId, + assistantMessageCount: 0, + ...(thinkingSelection + ? { + thinkingLevel: thinkingSelection.thinkingLevel, + } + : {}), + toolCalls: [], + toolResultCount: 0, + toolErrorCount: 0, + usedPrimaryText: false, + }, + }; + } + logException( error, "assistant_reply_generation_failed", diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index deb1458d..20b6daed 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -66,7 +66,9 @@ import { finalizeFailedTurnReply, getAgentTurnDiagnosticsAttributes, } from "@/chat/services/turn-failure-response"; +import { SLACK_SURFACE } from "@/chat/surface"; import { buildTurnContinuationResponse } from "@/chat/services/turn-continuation-response"; +import type { PlatformRuntimeConfig } from "@/chat/platform-config"; export interface ReplyExecutorServices { generateAssistantReply: typeof generateAssistantReplyImpl; @@ -83,6 +85,7 @@ export interface ReplyExecutorServices { interface ReplyExecutorDeps { getSlackAdapter: () => SlackAdapter; + platformConfig?: PlatformRuntimeConfig; resolveUserAttachments: ( attachments: Message["attachments"] | undefined, context: { @@ -358,6 +361,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const toolChannelId = preparedState.artifacts.assistantContextChannelId ?? channelId; let reply = await deps.services.generateAssistantReply(userText, { + surface: SLACK_SURFACE, requester: { userId: message.author.userId, userName: message.author.userName ?? fallbackIdentity?.userName, @@ -369,6 +373,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { piMessages: preparedState.conversation.piMessages, pendingAuth: preparedState.conversation.processing.pendingAuth, configuration: preparedState.configuration, + platformConfig: deps.platformConfig, channelConfiguration: preparedState.channelConfiguration, inboundAttachmentCount: message.attachments.length, omittedImageAttachmentCount, diff --git a/packages/junior/src/chat/runtime/thread-context.ts b/packages/junior/src/chat/runtime/thread-context.ts index e6cc25ac..1dac1460 100644 --- a/packages/junior/src/chat/runtime/thread-context.ts +++ b/packages/junior/src/chat/runtime/thread-context.ts @@ -15,6 +15,7 @@ function escapeRegExp(value: string): string { export function stripLeadingBotMention( text: string, options: { + botUserName?: string; stripLeadingSlackMentionToken?: boolean; } = {}, ): string { @@ -25,14 +26,16 @@ export function stripLeadingBotMention( next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim(); } + const botUserName = options.botUserName ?? botConfig.userName; + const mentionTarget = botUserName.trim().replace(/^@/, ""); const mentionByNameRe = new RegExp( - `^\\s*@${escapeRegExp(botConfig.userName)}\\b[\\s,:-]*`, + `^\\s*@${escapeRegExp(mentionTarget)}(?=$|[\\s,:;.!?])[\\s,:;.!?-]*`, "i", ); next = next.replace(mentionByNameRe, "").trim(); const mentionByLabeledEntityRe = new RegExp( - `^\\s*<@[^>|]+\\|${escapeRegExp(botConfig.userName)}>[\\s,:-]*`, + `^\\s*<@[^>|]+\\|${escapeRegExp(mentionTarget)}>[\\s,:;.!?-]*`, "i", ); next = next.replace(mentionByLabeledEntityRe, "").trim(); diff --git a/packages/junior/src/chat/sandbox/egress-policy.ts b/packages/junior/src/chat/sandbox/egress-policy.ts index 70d54e82..9494fc91 100644 --- a/packages/junior/src/chat/sandbox/egress-policy.ts +++ b/packages/junior/src/chat/sandbox/egress-policy.ts @@ -23,8 +23,10 @@ function manifestDomains(manifest: PluginManifest): string[] { return [...domains].sort((left, right) => left.localeCompare(right)); } -function providerEntries(): Array<{ provider: string; domains: string[] }> { - return getPluginProviders() +function providerEntries(options?: { + providerNames?: readonly string[]; +}): Array<{ provider: string; domains: string[] }> { + return getPluginProviders(options?.providerNames) .map((plugin) => ({ provider: plugin.manifest.name, domains: manifestDomains(plugin.manifest), @@ -57,8 +59,11 @@ function proxyUrl(egressId: string): string | undefined { /** Build the forwarding policy that keeps provider credentials outside the sandbox. */ export function buildSandboxEgressNetworkPolicy( egressId: string, + options?: { + providerNames?: readonly string[]; + }, ): NetworkPolicy | undefined { - const entries = providerEntries(); + const entries = providerEntries(options); if (entries.length === 0) { return undefined; } @@ -84,12 +89,12 @@ export function buildSandboxEgressNetworkPolicy( } /** Resolve non-secret command environment values for registered sandbox providers. */ -export async function resolveSandboxCommandEnvironment(): Promise< - Record -> { +export async function resolveSandboxCommandEnvironment(options?: { + providerNames?: readonly string[]; +}): Promise> { const env: Record = {}; - for (const plugin of getPluginProviders().sort((left, right) => - left.manifest.name.localeCompare(right.manifest.name), + for (const plugin of getPluginProviders(options?.providerNames).sort( + (left, right) => left.manifest.name.localeCompare(right.manifest.name), )) { Object.assign(env, resolvePluginCommandEnv(plugin.manifest)); const credentials = plugin.manifest.credentials; diff --git a/packages/junior/src/chat/sandbox/egress-proxy.ts b/packages/junior/src/chat/sandbox/egress-proxy.ts index fd8b457e..18c3f6db 100644 --- a/packages/junior/src/chat/sandbox/egress-proxy.ts +++ b/packages/junior/src/chat/sandbox/egress-proxy.ts @@ -276,6 +276,9 @@ export async function proxySandboxEgressRequest( if (!session) { return jsonError("Sandbox egress session is not authorized", 403); } + if (session.providerNames && !session.providerNames.includes(provider)) { + return jsonError("Provider is not enabled for this sandbox session", 403); + } let lease: SandboxEgressCredentialLease; try { diff --git a/packages/junior/src/chat/sandbox/egress-session.ts b/packages/junior/src/chat/sandbox/egress-session.ts index 849fd0b2..ac168ed9 100644 --- a/packages/junior/src/chat/sandbox/egress-session.ts +++ b/packages/junior/src/chat/sandbox/egress-session.ts @@ -7,7 +7,8 @@ const SANDBOX_EGRESS_LEASE_PREFIX = "sandbox-egress-lease"; const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000; export interface SandboxEgressSession { - requesterId: string; + providerNames?: string[]; + requesterId?: string; expiresAtMs: number; activationId: string; } @@ -27,7 +28,10 @@ function leaseKey( provider: string, session: SandboxEgressSession, ): string { - return `${SANDBOX_EGRESS_LEASE_PREFIX}:${egressId}:${provider}:${session.requesterId}:${session.activationId}`; + const requesterKey = session.requesterId + ? `user:${session.requesterId}` + : "host"; + return `${SANDBOX_EGRESS_LEASE_PREFIX}:${egressId}:${provider}:${requesterKey}:${session.activationId}`; } function parseSession(value: unknown): SandboxEgressSession | undefined { @@ -36,7 +40,11 @@ function parseSession(value: unknown): SandboxEgressSession | undefined { } const record = value as Partial; if ( - typeof record.requesterId !== "string" || + (record.providerNames !== undefined && + (!Array.isArray(record.providerNames) || + record.providerNames.some((name) => typeof name !== "string"))) || + (record.requesterId !== undefined && + typeof record.requesterId !== "string") || typeof record.expiresAtMs !== "number" || !Number.isFinite(record.expiresAtMs) || typeof record.activationId !== "string" || @@ -48,7 +56,10 @@ function parseSession(value: unknown): SandboxEgressSession | undefined { return undefined; } return { - requesterId: record.requesterId, + ...(record.providerNames + ? { providerNames: [...new Set(record.providerNames)].sort() } + : {}), + ...(record.requesterId ? { requesterId: record.requesterId } : {}), expiresAtMs: record.expiresAtMs, activationId: record.activationId, }; @@ -89,18 +100,23 @@ function parseLease(value: unknown): SandboxEgressCredentialLease | undefined { }; } -/** Persist requester authorization for credential activation by one forwarded VM session. */ +/** Persist credential activation context for one forwarded VM session. */ export async function upsertSandboxEgressSession(input: { egressId: string; - requesterId: string; + providerNames?: readonly string[]; + requesterId?: string; ttlMs?: number; }): Promise { const state = getStateAdapter(); await state.connect(); const ttlMs = Math.max(1, input.ttlMs ?? DEFAULT_SESSION_TTL_MS); const now = Date.now(); + const providerNames = input.providerNames + ? [...new Set(input.providerNames)].sort() + : undefined; const session: SandboxEgressSession = { - requesterId: input.requesterId, + ...(providerNames ? { providerNames } : {}), + ...(input.requesterId ? { requesterId: input.requesterId } : {}), expiresAtMs: now + ttlMs, activationId: randomUUID(), }; diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index db447191..5462427c 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -102,7 +102,8 @@ export function createSandboxExecutor(options?: { timeoutMs?: number; traceContext?: LogContext; credentialEgress?: { - requesterId: string; + providerNames?: readonly string[]; + requesterId?: string; }; onSandboxAcquired?: (sandbox: SandboxAcquiredState) => void | Promise; runBashCustomCommand?: ( @@ -117,6 +118,7 @@ export function createSandboxExecutor(options?: { ? async (egressId: string): Promise => { await upsertSandboxEgressSession({ egressId, + providerNames: credentialEgress.providerNames, requesterId: credentialEgress.requesterId, ttlMs: options?.timeoutMs, }); @@ -133,10 +135,16 @@ export function createSandboxExecutor(options?: { timeoutMs: options?.timeoutMs, traceContext, commandEnv: credentialEgress - ? async () => await resolveSandboxCommandEnvironment() + ? async () => + await resolveSandboxCommandEnvironment({ + providerNames: credentialEgress.providerNames, + }) : undefined, createNetworkPolicy: credentialEgress - ? buildSandboxEgressNetworkPolicy + ? (egressId) => + buildSandboxEgressNetworkPolicy(egressId, { + providerNames: credentialEgress.providerNames, + }) : undefined, beforeCommand: syncSandboxEgressSession, afterCommand: clearSandboxEgressSessionForCommand, diff --git a/packages/junior/src/chat/services/plugin-auth-orchestration.ts b/packages/junior/src/chat/services/plugin-auth-orchestration.ts index 55f07819..e1cf0829 100644 --- a/packages/junior/src/chat/services/plugin-auth-orchestration.ts +++ b/packages/junior/src/chat/services/plugin-auth-orchestration.ts @@ -35,6 +35,7 @@ export interface PluginAuthOrchestrationDeps { conversationId?: string; sessionId?: string; requesterId?: string; + providerNames?: readonly string[]; channelId?: string; threadTs?: string; userMessage: string; @@ -126,9 +127,9 @@ function explicitAuthRequiredProvider(details: unknown): string | undefined { return match?.[1]; } -function registeredProviderNames(): string[] { +function registeredProviderNames(allowedNames?: readonly string[]): string[] { const providers = new Set(); - for (const plugin of getPluginProviders()) { + for (const plugin of getPluginProviders(allowedNames)) { const domains = [ ...(plugin.manifest.credentials?.domains ?? []), ...(plugin.manifest.domains ?? []), @@ -279,7 +280,7 @@ export function createPluginAuthOrchestration( return { handleCommandFailure: async (input) => { - const providers = registeredProviderNames(); + const providers = registeredProviderNames(deps.providerNames); const explicitProvider = explicitAuthRequiredProvider(input.details); const provider = explicitProvider && providers.includes(explicitProvider) diff --git a/packages/junior/src/chat/skills.ts b/packages/junior/src/chat/skills.ts index 65213179..1e6207f2 100644 --- a/packages/junior/src/chat/skills.ts +++ b/packages/junior/src/chat/skills.ts @@ -232,6 +232,8 @@ export interface SkillInvocation { export interface DiscoverSkillsOptions { additionalRoots?: string[]; + allowedPluginNames?: readonly string[]; + allowedSkillNames?: readonly string[]; } let skillCache: { @@ -270,6 +272,30 @@ function resolveSkillRoots(options?: DiscoverSkillsOptions): string[] { return resolved; } +function filterDiscoveredSkills( + skills: SkillMetadata[], + options?: DiscoverSkillsOptions, +): SkillMetadata[] { + const allowedPlugins = options?.allowedPluginNames + ? new Set(options.allowedPluginNames) + : undefined; + const allowedSkills = options?.allowedSkillNames + ? new Set(options.allowedSkillNames) + : undefined; + + return skills.filter((skill) => { + if (allowedPlugins && skill.pluginProvider) { + if (!allowedPlugins.has(skill.pluginProvider)) { + return false; + } + } + if (allowedSkills && !allowedSkills.has(skill.name)) { + return false; + } + return true; + }); +} + function resolveSkillPlugin( meta: Pick, ): PluginDefinition | undefined { @@ -366,7 +392,15 @@ export async function discoverSkills( options?: DiscoverSkillsOptions, ): Promise { const roots = resolveSkillRoots(options); - const cacheKey = roots.join(path.delimiter); + const cacheKey = JSON.stringify({ + roots, + allowedPluginNames: options?.allowedPluginNames + ? [...options.allowedPluginNames].sort() + : undefined, + allowedSkillNames: options?.allowedSkillNames + ? [...options.allowedSkillNames].sort() + : undefined, + }); if ( skillCache && skillCache.expiresAt > Date.now() && @@ -408,7 +442,10 @@ export async function discoverSkills( } } - const sorted = discovered.sort((a, b) => a.name.localeCompare(b.name)); + const sorted = filterDiscoveredSkills( + discovered.sort((a, b) => a.name.localeCompare(b.name)), + options, + ); skillCache = { expiresAt: Date.now() + SKILL_CACHE_TTL_MS, key: cacheKey, diff --git a/packages/junior/src/chat/slack/app-home.ts b/packages/junior/src/chat/slack/app-home.ts index e095364b..1260da40 100644 --- a/packages/junior/src/chat/slack/app-home.ts +++ b/packages/junior/src/chat/slack/app-home.ts @@ -9,6 +9,7 @@ import type { PluginDefinition } from "@/chat/plugins/types"; import { discoverSkills } from "@/chat/skills"; import type { UserTokenStore } from "@/chat/credentials/user-token-store"; import { getRuntimeMetadata } from "@/chat/config"; +import type { PlatformRuntimeConfig } from "@/chat/platform-config"; interface HomeView { type: "home"; @@ -41,10 +42,15 @@ function loadDescriptionText(): string { return DEFAULT_DESCRIPTION_TEXT; } -async function buildSkillsSummaryText(): Promise { - const skills = (await discoverSkills()).filter( - (skill) => !HIDDEN_HOME_SKILLS.has(skill.name), - ); +async function buildSkillsSummaryText( + platformConfig?: PlatformRuntimeConfig, +): Promise { + const skills = ( + await discoverSkills({ + allowedPluginNames: platformConfig?.pluginNames, + allowedSkillNames: platformConfig?.skillNames, + }) + ).filter((skill) => !HIDDEN_HOME_SKILLS.has(skill.name)); if (skills.length === 0) { return "No skills installed."; } @@ -86,11 +92,12 @@ async function hasConnectedAccount( export async function buildHomeView( userId: string, userTokenStore: UserTokenStore, + platformConfig?: PlatformRuntimeConfig, ): Promise { const runtimeMetadata = getRuntimeMetadata(); const descriptionText = loadDescriptionText(); - const skillsSummaryText = await buildSkillsSummaryText(); - const providers = getPluginProviders(); + const skillsSummaryText = await buildSkillsSummaryText(platformConfig); + const providers = getPluginProviders(platformConfig?.pluginNames); const connectedSections: SectionBlock[] = []; for (const plugin of providers) { @@ -184,7 +191,8 @@ export async function publishAppHomeView( slackClient: WebClient, userId: string, userTokenStore: UserTokenStore, + platformConfig?: PlatformRuntimeConfig, ): Promise { - const view = await buildHomeView(userId, userTokenStore); + const view = await buildHomeView(userId, userTokenStore, platformConfig); await slackClient.views.publish({ user_id: userId, view }); } diff --git a/packages/junior/src/chat/tools/slack/canvas-tools.ts b/packages/junior/src/chat/slack/tools/canvas-tools.ts similarity index 99% rename from packages/junior/src/chat/tools/slack/canvas-tools.ts rename to packages/junior/src/chat/slack/tools/canvas-tools.ts index 5ed15546..d77dd402 100644 --- a/packages/junior/src/chat/tools/slack/canvas-tools.ts +++ b/packages/junior/src/chat/slack/tools/canvas-tools.ts @@ -6,7 +6,7 @@ import { lookupCanvasSection, readCanvas, updateCanvas, -} from "@/chat/tools/slack/canvases"; +} from "@/chat/slack/tools/canvases"; import { isConversationScopedChannel } from "@/chat/slack/client"; import { createOperationKey } from "@/chat/tools/idempotency"; import { logError, logWarn } from "@/chat/logging"; diff --git a/packages/junior/src/chat/tools/slack/canvases.ts b/packages/junior/src/chat/slack/tools/canvases.ts similarity index 100% rename from packages/junior/src/chat/tools/slack/canvases.ts rename to packages/junior/src/chat/slack/tools/canvases.ts diff --git a/packages/junior/src/chat/tools/channel-capabilities.ts b/packages/junior/src/chat/slack/tools/channel-capabilities.ts similarity index 51% rename from packages/junior/src/chat/tools/channel-capabilities.ts rename to packages/junior/src/chat/slack/tools/channel-capabilities.ts index 19afa3d3..adebf0e4 100644 --- a/packages/junior/src/chat/tools/channel-capabilities.ts +++ b/packages/junior/src/chat/slack/tools/channel-capabilities.ts @@ -2,21 +2,12 @@ import { isConversationChannel, isConversationScopedChannel, } from "@/chat/slack/client"; - -/** Declared capabilities of the current channel context. */ -export interface ChannelCapabilities { - /** Can create canvases in this channel (C/G/D channels). */ - canCreateCanvas: boolean; - /** Can post standalone messages to this channel (C/G channels only). */ - canPostToChannel: boolean; - /** Can add reactions to messages (C/G/D channels). */ - canAddReactions: boolean; -} +import type { ToolChannelCapabilities } from "@/chat/tools/types"; /** Resolve channel capabilities from a Slack channel ID. */ export function resolveChannelCapabilities( channelId: string | undefined, -): ChannelCapabilities { +): ToolChannelCapabilities { return { canCreateCanvas: isConversationScopedChannel(channelId), canPostToChannel: isConversationChannel(channelId), diff --git a/packages/junior/src/chat/tools/slack/channel-list-messages.ts b/packages/junior/src/chat/slack/tools/channel-list-messages.ts similarity index 100% rename from packages/junior/src/chat/tools/slack/channel-list-messages.ts rename to packages/junior/src/chat/slack/tools/channel-list-messages.ts diff --git a/packages/junior/src/chat/tools/slack/channel-post-message.ts b/packages/junior/src/chat/slack/tools/channel-post-message.ts similarity index 100% rename from packages/junior/src/chat/tools/slack/channel-post-message.ts rename to packages/junior/src/chat/slack/tools/channel-post-message.ts diff --git a/packages/junior/src/chat/tools/slack/list-tools.ts b/packages/junior/src/chat/slack/tools/list-tools.ts similarity index 99% rename from packages/junior/src/chat/tools/slack/list-tools.ts rename to packages/junior/src/chat/slack/tools/list-tools.ts index b841f865..fc26339d 100644 --- a/packages/junior/src/chat/tools/slack/list-tools.ts +++ b/packages/junior/src/chat/slack/tools/list-tools.ts @@ -5,7 +5,7 @@ import { createTodoList, listItems, updateListItem, -} from "@/chat/tools/slack/lists"; +} from "@/chat/slack/tools/lists"; import { createOperationKey } from "@/chat/tools/idempotency"; import type { ToolState } from "@/chat/tools/types"; diff --git a/packages/junior/src/chat/tools/slack/lists.ts b/packages/junior/src/chat/slack/tools/lists.ts similarity index 99% rename from packages/junior/src/chat/tools/slack/lists.ts rename to packages/junior/src/chat/slack/tools/lists.ts index 1b5d27d5..a031f663 100644 --- a/packages/junior/src/chat/tools/slack/lists.ts +++ b/packages/junior/src/chat/slack/tools/lists.ts @@ -111,9 +111,7 @@ const DEFAULT_TODO_SCHEMA = [ ]; /** Create a new Slack todo list with the default schema. */ -export async function createTodoList( - name: string, -): Promise<{ +export async function createTodoList(name: string): Promise<{ listId: string; listColumnMap: ListColumnMap; permalink?: string; diff --git a/packages/junior/src/chat/tools/slack/message-add-reaction.ts b/packages/junior/src/chat/slack/tools/message-add-reaction.ts similarity index 100% rename from packages/junior/src/chat/tools/slack/message-add-reaction.ts rename to packages/junior/src/chat/slack/tools/message-add-reaction.ts diff --git a/packages/junior/src/chat/tools/slack/slack-message-url.ts b/packages/junior/src/chat/slack/tools/slack-message-url.ts similarity index 100% rename from packages/junior/src/chat/tools/slack/slack-message-url.ts rename to packages/junior/src/chat/slack/tools/slack-message-url.ts diff --git a/packages/junior/src/chat/tools/slack/thread-read.ts b/packages/junior/src/chat/slack/tools/thread-read.ts similarity index 99% rename from packages/junior/src/chat/tools/slack/thread-read.ts rename to packages/junior/src/chat/slack/tools/thread-read.ts index 0c11192a..e129e7d7 100644 --- a/packages/junior/src/chat/tools/slack/thread-read.ts +++ b/packages/junior/src/chat/slack/tools/thread-read.ts @@ -8,7 +8,7 @@ import { tool } from "@/chat/tools/definition"; import { SLACK_TS_PATTERN, parseSlackMessageReference, -} from "@/chat/tools/slack/slack-message-url"; +} from "@/chat/slack/tools/slack-message-url"; import type { SlackThreadReply } from "@/chat/slack/channel"; import type { ToolRuntimeContext } from "@/chat/tools/types"; import { renderSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments"; diff --git a/packages/junior/src/chat/tools/slack/user-lookup.ts b/packages/junior/src/chat/slack/tools/user-lookup.ts similarity index 100% rename from packages/junior/src/chat/tools/slack/user-lookup.ts rename to packages/junior/src/chat/slack/tools/user-lookup.ts diff --git a/packages/junior/src/chat/slack/webhook-preprocess.ts b/packages/junior/src/chat/slack/webhook-preprocess.ts new file mode 100644 index 00000000..5f7ed420 --- /dev/null +++ b/packages/junior/src/chat/slack/webhook-preprocess.ts @@ -0,0 +1,157 @@ +import type { Adapter, Message } from "chat"; +import { + extractMessageChangedMention, + isMessageChangedEnvelope, +} from "@/chat/ingress/message-changed"; +import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; +import { logException } from "@/chat/logging"; +import { rehydrateAttachmentFetchers } from "@/chat/queue/thread-message-dispatcher"; +import type { WaitUntilFn } from "@/handlers/types"; + +interface SlackWebhookAuthAdapter extends Adapter { + botUserId?: string; + defaultBotTokenProvider?: () => string | Promise; + requestContext?: { + run(context: unknown, fn: () => T): T; + }; + resolveTokenForTeam?: (teamId: string) => Promise; + verifySignature: ( + body: string, + timestamp: string | null, + signature: string | null, + ) => boolean; +} + +interface SlackWebhookBot { + getAdapter(name: "slack"): Adapter; + initialize(): Promise; + processMessage( + adapter: Adapter, + threadId: string, + message: Message, + options: { waitUntil: (task: Promise) => void }, + ): void; +} + +function getSlackPayloadTeamId(body: unknown): string | undefined { + if (!body || typeof body !== "object") { + return undefined; + } + + const teamId = (body as Record).team_id; + return typeof teamId === "string" && teamId.length > 0 ? teamId : undefined; +} + +async function handleAuthenticatedSlackMessageChangedMention(args: { + body: unknown; + bot: SlackWebhookBot; + rawBody: string; + request: Request; + waitUntil: WaitUntilFn; +}): Promise { + const slackAdapter = args.bot.getAdapter("slack"); + const authAdapter = slackAdapter as SlackWebhookAuthAdapter; + const timestamp = args.request.headers.get("x-slack-request-timestamp"); + const signature = args.request.headers.get("x-slack-signature"); + + // Reuse the adapter's own Slack signature verification before dispatching + // the synthetic edit event so this side-channel cannot bypass auth. + if (!authAdapter.verifySignature(args.rawBody, timestamp, signature)) { + return; + } + + // Chat SDK initializes adapters automatically inside webhook handling. This + // side-channel runs before the SDK handler, so it must join that lifecycle. + await args.bot.initialize(); + + const webhookOptions = { + waitUntil: (task: Promise) => args.waitUntil(task), + }; + const dispatch = () => { + const botUserId = authAdapter.botUserId; + if (!botUserId) { + return false; + } + + const result = extractMessageChangedMention( + args.body, + botUserId, + slackAdapter, + ); + if (!result) { + return false; + } + + rehydrateAttachmentFetchers(result.message); + args.bot.processMessage( + slackAdapter, + result.threadId, + result.message, + webhookOptions, + ); + return true; + }; + + if (authAdapter.defaultBotTokenProvider) { + dispatch(); + return; + } + + const teamId = getSlackPayloadTeamId(args.body); + if ( + !teamId || + !authAdapter.resolveTokenForTeam || + !authAdapter.requestContext + ) { + return; + } + + const context = await authAdapter.resolveTokenForTeam(teamId); + if (!context) { + return; + } + + authAdapter.requestContext.run(context, dispatch); +} + +/** Rebuild Slack webhook requests after inspecting body-owned side channels. */ +export async function prepareSlackWebhookRequest(args: { + bot: SlackWebhookBot; + request: Request; + waitUntil: WaitUntilFn; +}): Promise<{ request: Request; runWithWorkspace(fn: () => T): T }> { + const rawBody = await args.request.text(); + let parsedBody: unknown; + try { + parsedBody = JSON.parse(rawBody); + } catch { + parsedBody = undefined; + } + + const teamId = getSlackPayloadTeamId(parsedBody); + + if (parsedBody && isMessageChangedEnvelope(parsedBody)) { + try { + await runWithWorkspaceTeamId(teamId, () => + handleAuthenticatedSlackMessageChangedMention({ + body: parsedBody, + bot: args.bot, + rawBody, + request: args.request, + waitUntil: args.waitUntil, + }), + ); + } catch (error) { + logException(error, "slack_message_changed_side_channel_failed"); + } + } + + return { + request: new Request(args.request.url, { + method: args.request.method, + headers: args.request.headers, + body: rawBody, + }), + runWithWorkspace: (fn) => runWithWorkspaceTeamId(teamId, fn), + }; +} diff --git a/packages/junior/src/chat/surface.ts b/packages/junior/src/chat/surface.ts new file mode 100644 index 00000000..d0d68aa3 --- /dev/null +++ b/packages/junior/src/chat/surface.ts @@ -0,0 +1,21 @@ +export type AssistantSurfacePlatform = "slack" | "github"; +export type AssistantSurfaceOutputFormat = "slack-mrkdwn" | "github-gfm"; +export type AssistantSurfaceToolProfile = "slack" | "github-comment"; + +export interface AssistantSurface { + outputFormat: AssistantSurfaceOutputFormat; + platform: AssistantSurfacePlatform; + toolProfile: AssistantSurfaceToolProfile; +} + +export const SLACK_SURFACE: AssistantSurface = { + platform: "slack", + outputFormat: "slack-mrkdwn", + toolProfile: "slack", +}; + +export const GITHUB_COMMENT_SURFACE: AssistantSurface = { + platform: "github", + outputFormat: "github-gfm", + toolProfile: "github-comment", +}; diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index c57afb15..66fb63ff 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -11,22 +11,22 @@ import { createLoadSkillTool } from "@/chat/tools/skill/load-skill"; import { createSearchMcpToolsTool } from "@/chat/tools/skill/search-mcp-tools"; import { createReadFileTool } from "@/chat/tools/sandbox/read-file"; import { createReportProgressTool } from "@/chat/tools/runtime/report-progress"; -import { createSlackChannelListMessagesTool } from "@/chat/tools/slack/channel-list-messages"; -import { createSlackChannelPostMessageTool } from "@/chat/tools/slack/channel-post-message"; -import { createSlackMessageAddReactionTool } from "@/chat/tools/slack/message-add-reaction"; +import { createSlackChannelListMessagesTool } from "@/chat/slack/tools/channel-list-messages"; +import { createSlackChannelPostMessageTool } from "@/chat/slack/tools/channel-post-message"; +import { createSlackMessageAddReactionTool } from "@/chat/slack/tools/message-add-reaction"; import { createSlackCanvasCreateTool, createSlackCanvasReadTool, createSlackCanvasUpdateTool, -} from "@/chat/tools/slack/canvas-tools"; +} from "@/chat/slack/tools/canvas-tools"; import { createSlackListAddItemsTool, createSlackListCreateTool, createSlackListGetItemsTool, createSlackListUpdateItemTool, -} from "@/chat/tools/slack/list-tools"; -import { createSlackThreadReadTool } from "@/chat/tools/slack/thread-read"; -import { createSlackUserLookupTool } from "@/chat/tools/slack/user-lookup"; +} from "@/chat/slack/tools/list-tools"; +import { createSlackThreadReadTool } from "@/chat/slack/tools/thread-read"; +import { createSlackUserLookupTool } from "@/chat/slack/tools/user-lookup"; import { createSystemTimeTool } from "@/chat/tools/system-time"; import { createAdvisorTool } from "@/chat/tools/advisor/tool"; import type { ToolDefinition } from "@/chat/tools/definition"; @@ -109,14 +109,6 @@ export function createTools( hooks, hooks.toolOverrides?.imageGenerate, ), - slackCanvasRead: createSlackCanvasReadTool(), - slackCanvasUpdate: createSlackCanvasUpdateTool(state, context), - slackThreadRead: createSlackThreadReadTool(context), - slackUserLookup: createSlackUserLookupTool(), - slackListCreate: createSlackListCreateTool(state), - slackListAddItems: createSlackListAddItemsTool(state), - slackListGetItems: createSlackListGetItemsTool(state), - slackListUpdateItem: createSlackListUpdateItemTool(state), }; if (context.advisor) { @@ -134,26 +126,37 @@ export function createTools( ); } - const { channelCapabilities } = context; + if (context.toolProfile === "slack") { + tools.slackCanvasRead = createSlackCanvasReadTool(); + tools.slackCanvasUpdate = createSlackCanvasUpdateTool(state, context); + tools.slackThreadRead = createSlackThreadReadTool(context); + tools.slackUserLookup = createSlackUserLookupTool(); + tools.slackListCreate = createSlackListCreateTool(state); + tools.slackListAddItems = createSlackListAddItemsTool(state); + tools.slackListGetItems = createSlackListGetItemsTool(state); + tools.slackListUpdateItem = createSlackListUpdateItemTool(state); - if (channelCapabilities.canCreateCanvas) { - tools.slackCanvasCreate = createSlackCanvasCreateTool(context, state); - } + const { channelCapabilities } = context; - if (channelCapabilities.canPostToChannel) { - tools.slackChannelPostMessage = createSlackChannelPostMessageTool( - context, - state, - ); - tools.slackChannelListMessages = - createSlackChannelListMessagesTool(context); - } + if (channelCapabilities.canCreateCanvas) { + tools.slackCanvasCreate = createSlackCanvasCreateTool(context, state); + } - if (channelCapabilities.canAddReactions) { - tools.slackMessageAddReaction = createSlackMessageAddReactionTool( - context, - state, - ); + if (channelCapabilities.canPostToChannel) { + tools.slackChannelPostMessage = createSlackChannelPostMessageTool( + context, + state, + ); + tools.slackChannelListMessages = + createSlackChannelListMessagesTool(context); + } + + if (channelCapabilities.canAddReactions) { + tools.slackMessageAddReaction = createSlackMessageAddReactionTool( + context, + state, + ); + } } return tools; diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index c4ca9ed8..06ca88c2 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -3,14 +3,20 @@ import type { McpToolManager } from "@/chat/mcp/tool-manager"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { Skill } from "@/chat/skills"; +import type { AssistantSurfaceToolProfile } from "@/chat/surface"; import type { LoadSkillMetadata } from "@/chat/tools/skill/load-skill"; -import type { ChannelCapabilities } from "@/chat/tools/channel-capabilities"; import type { AdvisorToolRuntimeContext } from "@/chat/tools/advisor/tool"; export interface ImageGenerateToolDeps { fetch?: typeof fetch; } +export interface ToolChannelCapabilities { + canAddReactions: boolean; + canCreateCanvas: boolean; + canPostToChannel: boolean; +} + export interface ToolHooks { getGeneratedFile?: (filename: string) => FileUpload | undefined; onGeneratedArtifactFiles?: (files: FileUpload[]) => void; @@ -29,7 +35,8 @@ export interface ToolHooks { export interface ToolRuntimeContext { advisor?: AdvisorToolRuntimeContext; channelId?: string; - channelCapabilities: ChannelCapabilities; + channelCapabilities: ToolChannelCapabilities; + toolProfile: AssistantSurfaceToolProfile; messageTs?: string; threadTs?: string; userText?: string; diff --git a/packages/junior/src/handlers/webhooks.ts b/packages/junior/src/handlers/webhooks.ts index e74da956..4889d67b 100644 --- a/packages/junior/src/handlers/webhooks.ts +++ b/packages/junior/src/handlers/webhooks.ts @@ -1,10 +1,4 @@ -import { getProductionBot } from "@/chat/app/production"; -import { - extractMessageChangedMention, - isMessageChangedEnvelope, -} from "@/chat/ingress/message-changed"; -import { rehydrateAttachmentFetchers } from "@/chat/queue/thread-message-dispatcher"; -import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; +import type { ProductionBot } from "@/chat/app/production"; import { createRequestContext, logException, @@ -16,116 +10,20 @@ import { } from "@/chat/logging"; import type { WaitUntilFn } from "@/handlers/types"; -interface SlackWebhookAuthAdapter { - botUserId?: string; - defaultBotTokenProvider?: () => string | Promise; - requestContext?: { - run(context: unknown, fn: () => T): T; - }; - resolveTokenForTeam?: (teamId: string) => Promise; - verifySignature: ( - body: string, - timestamp: string | null, - signature: string | null, - ) => boolean; -} - -function getSlackPayloadTeamId(body: unknown): string | undefined { - if (!body || typeof body !== "object") { - return undefined; - } - - const teamId = (body as Record).team_id; - return typeof teamId === "string" && teamId.length > 0 ? teamId : undefined; -} - -async function handleAuthenticatedSlackMessageChangedMention(args: { - body: unknown; - bot: ReturnType; - rawBody: string; - request: Request; - waitUntil: WaitUntilFn; -}): Promise { - const slackAdapter = args.bot.getAdapter("slack"); - const authAdapter = slackAdapter as unknown as SlackWebhookAuthAdapter; - const timestamp = args.request.headers.get("x-slack-request-timestamp"); - const signature = args.request.headers.get("x-slack-signature"); - - // Reuse the adapter's own Slack signature verification before dispatching - // the synthetic edit event so this side-channel cannot bypass auth. - if (!authAdapter.verifySignature(args.rawBody, timestamp, signature)) { - return; - } - - // Chat SDK initializes adapters automatically inside webhook handling. This - // side-channel runs before the SDK handler, so it must join that lifecycle. - await args.bot.initialize(); - - const webhookOptions = { - waitUntil: (task: Promise) => args.waitUntil(task), - }; - const dispatch = () => { - const botUserId = authAdapter.botUserId; - if (!botUserId) { - return false; - } - - const result = extractMessageChangedMention( - args.body, - botUserId, - slackAdapter, - ); - if (!result) { - return false; - } - - rehydrateAttachmentFetchers(result.message); - args.bot.processMessage( - slackAdapter, - result.threadId, - result.message, - webhookOptions, - ); - return true; - }; - - if (authAdapter.defaultBotTokenProvider) { - dispatch(); - return; - } - - const teamId = getSlackPayloadTeamId(args.body); - if ( - !teamId || - !authAdapter.resolveTokenForTeam || - !authAdapter.requestContext - ) { - return; - } - - const context = await authAdapter.resolveTokenForTeam(teamId); - if (!context) { - return; - } - - authAdapter.requestContext.run(context, dispatch); -} - /** * Handles `POST /api/webhooks/:platform`. * * The router only resolves the platform and delegates to the adapter webhook * implementation; request semantics stay owned by the adapter package. * - * For Slack, the body is read once and used to detect `message_changed` events - * that introduce a new bot @mention, which the Slack adapter silently ignores. - * The request is then reconstructed so the adapter can consume it normally. + * Platform-owned preprocessors may rebuild the request before delegation when + * an adapter has side-channel behavior the generic router should not own. */ export async function handlePlatformWebhook( request: Request, platform: string, waitUntil: WaitUntilFn, - bot = getProductionBot(), + bot: ProductionBot, ): Promise { const handler = bot.webhooks[platform as keyof typeof bot.webhooks]; const requestContext = createRequestContext(request, { platform }); @@ -146,44 +44,18 @@ export async function handlePlatformWebhook( return new Response(`Unknown platform: ${platform}`, { status: 404 }); } - // For Slack webhooks, peek the body to handle `message_changed` events - // that introduce a new bot @mention. The Slack adapter drops these subtypes, - // so we dispatch them as a synthesized mention before forwarding to the adapter. let rebuiltRequest = request; - let slackWorkspaceTeamId: string | undefined; + let runWithPlatformContext = (fn: () => T): T => fn(); if (platform === "slack") { - const rawBody = await request.text(); - let parsedBody: unknown; - try { - parsedBody = JSON.parse(rawBody); - } catch { - parsedBody = undefined; - } - - slackWorkspaceTeamId = getSlackPayloadTeamId(parsedBody); - - if (parsedBody && isMessageChangedEnvelope(parsedBody)) { - try { - await runWithWorkspaceTeamId(slackWorkspaceTeamId, () => - handleAuthenticatedSlackMessageChangedMention({ - body: parsedBody, - bot, - rawBody, - request, - waitUntil, - }), - ); - } catch (error) { - logException(error, "slack_message_changed_side_channel_failed"); - } - } - - // Reconstruct the request so the adapter can read the body. - rebuiltRequest = new Request(request.url, { - method: request.method, - headers: request.headers, - body: rawBody, + const { prepareSlackWebhookRequest } = + await import("@/chat/slack/webhook-preprocess"); + const prepared = await prepareSlackWebhookRequest({ + bot, + request, + waitUntil, }); + rebuiltRequest = prepared.request; + runWithPlatformContext = prepared.runWithWorkspace; } try { @@ -193,12 +65,10 @@ export async function handlePlatformWebhook( requestContext, async () => { try { - const response = await runWithWorkspaceTeamId( - slackWorkspaceTeamId, - () => - handler(rebuiltRequest, { - waitUntil: (task: Promise) => waitUntil(task), - } as Parameters[1]), + const response = await runWithPlatformContext(() => + handler(rebuiltRequest, { + waitUntil: (task: Promise) => waitUntil(task), + } as Parameters[1]), ); if (response.status >= 400) { let responseBodySnippet: string | undefined; @@ -210,16 +80,22 @@ export async function handlePlatformWebhook( } catch { responseBodySnippet = undefined; } + const platformAttributes = + platform === "slack" + ? { + "http.request.header.x_slack_signature": + request.headers.get("x-slack-signature") ?? undefined, + "http.request.header.x_slack_request_timestamp": + request.headers.get("x-slack-request-timestamp") ?? + undefined, + } + : {}; logWarn( "webhook_non_success_response", {}, { "http.response.status_code": response.status, - "http.request.header.x_slack_signature": - request.headers.get("x-slack-signature") ?? undefined, - "http.request.header.x_slack_request_timestamp": - request.headers.get("x-slack-request-timestamp") ?? - undefined, + ...platformAttributes, ...(responseBodySnippet ? { "app.webhook.response_body": responseBodySnippet } : {}), @@ -248,11 +124,3 @@ export async function handlePlatformWebhook( } }); } - -export async function POST( - request: Request, - platform: string, - waitUntil: WaitUntilFn, -): Promise { - return handlePlatformWebhook(request, platform, waitUntil); -} diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index 163b27c7..859f3f8e 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -1,6 +1,8 @@ import path from "node:path"; import type { Nitro } from "nitro/types"; import { applyRolldownTreeshakeWorkaround } from "@/build/rolldown-workarounds"; +import type { ChatPlatform } from "@/chat/platforms"; +import type { JuniorPlatformOptionsMap } from "@/chat/platform-config"; import { copyAppAndPluginContent, copyIncludedFiles, @@ -9,8 +11,10 @@ import { injectVirtualConfig } from "@/build/virtual-config"; export interface JuniorNitroOptions { cwd?: string; + enabledPlatforms?: readonly ChatPlatform[]; maxDuration?: number; pluginPackages?: string[]; + platforms?: JuniorPlatformOptionsMap; /** * Extra file patterns to copy into the server output for files that the * bundler cannot trace (e.g. dynamically imported providers). @@ -37,7 +41,11 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { options.maxDuration ?? 800; applyRolldownTreeshakeWorkaround(nitro); - injectVirtualConfig(nitro, options.pluginPackages ?? []); + injectVirtualConfig(nitro, { + enabledPlatforms: options.enabledPlatforms, + pluginPackages: options.pluginPackages ?? [], + platforms: options.platforms, + }); nitro.hooks.hook("compiled", () => { copyAppAndPluginContent( diff --git a/packages/junior/src/virtual-modules.d.ts b/packages/junior/src/virtual-modules.d.ts index 67287d25..8987a4c6 100644 --- a/packages/junior/src/virtual-modules.d.ts +++ b/packages/junior/src/virtual-modules.d.ts @@ -1,4 +1,8 @@ /** Virtual module injected by juniorNitro() at build time. */ declare module "#junior/config" { + export const enabledPlatforms: string[] | undefined; export const pluginPackages: string[]; + export const platforms: + | import("@/chat/platform-config").JuniorPlatformOptionsMap + | undefined; } diff --git a/packages/junior/tests/fixtures/github/factories/webhooks.ts b/packages/junior/tests/fixtures/github/factories/webhooks.ts new file mode 100644 index 00000000..328700b3 --- /dev/null +++ b/packages/junior/tests/fixtures/github/factories/webhooks.ts @@ -0,0 +1,125 @@ +import { createHmac } from "node:crypto"; + +interface BaseWebhookOptions { + body?: string; + commentId?: number; + installationId?: number; + owner?: string; + repo?: string; + senderId?: number; + senderLogin?: string; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +export function signGitHubWebhookBody( + body: string, + webhookSecret: string, +): string { + return `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; +} + +export function githubIssueCommentWebhook( + options: BaseWebhookOptions & { + issueNumber: number; + isPullRequest: boolean; + }, +) { + const owner = options.owner ?? "acme"; + const repo = options.repo ?? "junior"; + const senderId = options.senderId ?? 42; + const senderLogin = options.senderLogin ?? "octocat"; + const commentId = options.commentId ?? 1_001; + const createdAt = nowIso(); + return { + action: "created", + installation: { + id: options.installationId ?? 1, + }, + issue: { + number: options.issueNumber, + ...(options.isPullRequest + ? { pull_request: { url: "https://api.github.com/pr/1" } } + : {}), + }, + comment: { + id: commentId, + body: options.body ?? "@junior please help", + created_at: createdAt, + updated_at: createdAt, + user: { + id: senderId, + login: senderLogin, + type: "User", + }, + }, + repository: { + id: 501, + name: repo, + full_name: `${owner}/${repo}`, + owner: { + id: 77, + login: owner, + type: "Organization", + }, + }, + sender: { + id: senderId, + login: senderLogin, + type: "User", + }, + }; +} + +export function githubReviewCommentWebhook( + options: BaseWebhookOptions & { + pullRequestNumber: number; + reviewCommentId?: number; + inReplyToId?: number; + }, +) { + const owner = options.owner ?? "acme"; + const repo = options.repo ?? "junior"; + const senderId = options.senderId ?? 42; + const senderLogin = options.senderLogin ?? "octocat"; + const commentId = options.reviewCommentId ?? 2_001; + const createdAt = nowIso(); + return { + action: "created", + installation: { + id: options.installationId ?? 1, + }, + pull_request: { + number: options.pullRequestNumber, + }, + comment: { + id: commentId, + in_reply_to_id: options.inReplyToId, + body: options.body ?? "@junior can you review this line?", + created_at: createdAt, + updated_at: createdAt, + user: { + id: senderId, + login: senderLogin, + type: "User", + }, + }, + repository: { + id: 501, + name: repo, + full_name: `${owner}/${repo}`, + owner: { + id: 77, + login: owner, + type: "Organization", + }, + }, + sender: { + id: senderId, + login: senderLogin, + type: "User", + }, + }; +} diff --git a/packages/junior/tests/integration/advisor/advisor-tool.test.ts b/packages/junior/tests/integration/advisor/advisor-tool.test.ts index 380bcaf5..a3566049 100644 --- a/packages/junior/tests/integration/advisor/advisor-tool.test.ts +++ b/packages/junior/tests/integration/advisor/advisor-tool.test.ts @@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox"; import type { AdvisorConfig } from "@/chat/config"; import type { PiMessage } from "@/chat/pi/messages"; import { createTools } from "@/chat/tools"; -import { resolveChannelCapabilities } from "@/chat/tools/channel-capabilities"; +import { resolveChannelCapabilities } from "@/chat/slack/tools/channel-capabilities"; import type { AdvisorSessionStore } from "@/chat/tools/advisor/session-store"; import { createAdvisorToolDefinitions, @@ -91,6 +91,7 @@ describe("advisor tool", () => { const baseContext = { channelCapabilities: resolveChannelCapabilities("D12345"), sandbox: {} as any, + toolProfile: "slack" as const, }; expect(createTools([], {}, baseContext)).not.toHaveProperty("advisor"); @@ -185,6 +186,7 @@ describe("advisor tool", () => { { channelCapabilities: resolveChannelCapabilities("C12345"), sandbox: {} as any, + toolProfile: "slack", }, ), ); diff --git a/packages/junior/tests/integration/app-platforms.test.ts b/packages/junior/tests/integration/app-platforms.test.ts new file mode 100644 index 00000000..46772973 --- /dev/null +++ b/packages/junior/tests/integration/app-platforms.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createApp } from "@/app"; + +describe("app platform route wiring", () => { + const originalSlackSigningSecret = process.env.SLACK_SIGNING_SECRET; + const originalGitHubBotUsername = process.env.GITHUB_BOT_USERNAME; + + afterEach(() => { + if (originalSlackSigningSecret === undefined) { + delete process.env.SLACK_SIGNING_SECRET; + } else { + process.env.SLACK_SIGNING_SECRET = originalSlackSigningSecret; + } + + if (originalGitHubBotUsername === undefined) { + delete process.env.GITHUB_BOT_USERNAME; + } else { + process.env.GITHUB_BOT_USERNAME = originalGitHubBotUsername; + } + }); + + it("does not expose Slack-only routes in a GitHub-only app", async () => { + delete process.env.SLACK_SIGNING_SECRET; + const app = await createApp({ enabledPlatforms: ["github"] }); + + expect( + ( + await app.request("/api/internal/turn-resume", { + method: "POST", + }) + ).status, + ).toBe(404); + expect((await app.request("/api/oauth/callback/sentry")).status).toBe(404); + expect( + (await app.request("/api/oauth/callback/mcp/demo?state=x&code=y")).status, + ).toBe(404); + }); + + it("rejects disabled webhook platforms before initializing a bot", async () => { + delete process.env.SLACK_SIGNING_SECRET; + delete process.env.GITHUB_BOT_USERNAME; + const app = await createApp({ enabledPlatforms: ["github"] }); + + const response = await app.request("/api/webhooks/slack", { + method: "POST", + body: "{}", + }); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("Unknown platform: slack"); + }); + + it("keeps GitHub webhook disabled by default without initializing Slack", async () => { + delete process.env.SLACK_SIGNING_SECRET; + const app = await createApp(); + + const response = await app.request("/api/webhooks/github", { + method: "POST", + body: "{}", + }); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("Unknown platform: github"); + }); +}); diff --git a/packages/junior/tests/integration/github/mention-webhook.test.ts b/packages/junior/tests/integration/github/mention-webhook.test.ts new file mode 100644 index 00000000..da6fdc62 --- /dev/null +++ b/packages/junior/tests/integration/github/mention-webhook.test.ts @@ -0,0 +1,392 @@ +import type { Message } from "chat"; +import { createGitHubAdapter } from "@chat-adapter/github"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createGitHubRuntime } from "@/chat/app/factory"; +import type { JuniorRuntimeServiceOverrides } from "@/chat/app/services"; +import { JuniorChat } from "@/chat/ingress/junior-chat"; +import { RetryableTurnError } from "@/chat/runtime/turn"; +import type { WaitUntilFn } from "@/handlers/types"; +import { handlePlatformWebhook } from "@/handlers/webhooks"; +import { + githubIssueCommentWebhook, + githubReviewCommentWebhook, + signGitHubWebhookBody, +} from "../../fixtures/github/factories/webhooks"; +import { getCapturedGitHubApiCalls } from "../../msw/handlers/github-api"; + +vi.mock("@sentry/node", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureException: vi.fn(() => undefined), + }; +}); + +const GITHUB_WEBHOOK_SECRET = "github-webhook-secret"; + +async function flushWaitUntil(tasks: Array>): Promise { + for (let index = 0; index < tasks.length; index += 1) { + await tasks[index]; + } +} + +function collectWaitUntil(tasks: Array>): WaitUntilFn { + return (task) => { + tasks.push(typeof task === "function" ? task() : task); + }; +} + +function createGitHubRequest(args: { + eventType: "issue_comment" | "pull_request_review_comment"; + payload: Record; + signatureOverride?: string; +}): Request { + const body = JSON.stringify(args.payload); + return new Request("https://example.test/api/webhooks/github", { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": args.eventType, + "x-hub-signature-256": + args.signatureOverride ?? + signGitHubWebhookBody(body, GITHUB_WEBHOOK_SECRET), + }, + body, + }); +} + +function makeDiagnostics() { + return { + assistantMessageCount: 1, + modelId: "fake-agent-model", + outcome: "success" as const, + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }; +} + +describe("GitHub webhook mention handling", () => { + afterEach(() => { + delete process.env.GITHUB_BOT_USERNAME; + vi.restoreAllMocks(); + }); + + async function createGitHubBot( + generateAssistantReply?: NonNullable< + JuniorRuntimeServiceOverrides["replyExecutor"] + >["generateAssistantReply"], + ) { + process.env.GITHUB_BOT_USERNAME = "junior"; + const bot = new JuniorChat({ + userName: "junior", + adapters: { + github: createGitHubAdapter({ + token: "ghs_test_token", + webhookSecret: GITHUB_WEBHOOK_SECRET, + userName: "junior", + }), + }, + state: createMemoryState(), + }); + const runtime = createGitHubRuntime({ + services: { + replyExecutor: { + generateAssistantReply: + generateAssistantReply ?? + (async () => ({ + text: "GitHub final reply", + diagnostics: makeDiagnostics(), + })), + }, + }, + }); + const handledMessages: Array< + Pick + > = []; + bot.onNewMention((thread, message) => { + handledMessages.push({ + id: message.id, + isMention: message.isMention, + text: message.text, + threadId: message.threadId, + }); + return runtime.handleNewMention(thread, message); + }); + return { bot, handledMessages }; + } + + it("rejects invalid webhook signatures", async () => { + const { bot } = await createGitHubBot(); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 11, + isPullRequest: false, + body: "@junior investigate this", + }) as Record; + const request = createGitHubRequest({ + eventType: "issue_comment", + payload, + signatureOverride: "sha256=forged", + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(401); + const postCalls = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + (entry.url.includes("/issues/") || entry.url.includes("/pulls/")), + ); + expect(postCalls).toHaveLength(0); + }); + + it("replies to explicit mentions in issue comments", async () => { + const { bot, handledMessages } = await createGitHubBot(); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 11, + isPullRequest: false, + body: "@junior can you debug this issue?", + }) as Record; + const request = createGitHubRequest({ + eventType: "issue_comment", + payload, + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(200); + expect(handledMessages).toHaveLength(1); + expect(handledMessages[0]).toMatchObject({ + isMention: true, + }); + const commentPosts = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + entry.url.includes("/repos/acme/junior/issues/11/comments"), + ); + expect(commentPosts).toHaveLength(1); + expect(commentPosts[0]?.body).toMatchObject({ + body: "GitHub final reply", + }); + }); + + it("posts a failure reply when Sentry is not configured", async () => { + const { bot } = await createGitHubBot(async () => { + throw new Error("agent failed"); + }); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 13, + isPullRequest: false, + body: "@junior please handle this failure", + }) as Record; + const request = createGitHubRequest({ + eventType: "issue_comment", + payload, + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(200); + const commentPosts = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + entry.url.includes("/repos/acme/junior/issues/13/comments"), + ); + expect(commentPosts).toHaveLength(1); + expect(commentPosts[0]?.body).toMatchObject({ + body: expect.stringContaining("event_id=unknown"), + }); + }); + + it("posts an auth unavailable reply when auth pause reaches GitHub", async () => { + const { bot } = await createGitHubBot(async () => { + throw new RetryableTurnError("plugin_auth_resume", "github auth needed", { + authProvider: "github", + }); + }); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 14, + isPullRequest: false, + body: "@junior please use my GitHub account", + }) as Record; + const request = createGitHubRequest({ + eventType: "issue_comment", + payload, + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(200); + const commentPosts = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + entry.url.includes("/repos/acme/junior/issues/14/comments"), + ); + expect(commentPosts).toHaveLength(1); + expect(commentPosts[0]?.body).toMatchObject({ + body: expect.stringContaining( + "requires interactive github authorization", + ), + }); + }); + + it("relies on Chat SDK message dedupe for repeated webhook deliveries", async () => { + const { bot, handledMessages } = await createGitHubBot(); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 12, + isPullRequest: false, + commentId: 9_001, + body: "@junior handle this once", + }) as Record; + + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await handlePlatformWebhook( + createGitHubRequest({ + eventType: "issue_comment", + payload, + }), + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + expect(response.status).toBe(200); + } + await flushWaitUntil(waitUntilTasks); + + expect(handledMessages).toHaveLength(1); + const commentPosts = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + entry.url.includes("/repos/acme/junior/issues/12/comments"), + ); + expect(commentPosts).toHaveLength(1); + }); + + it("replies to explicit mentions in PR conversation comments", async () => { + const { bot } = await createGitHubBot(); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 22, + isPullRequest: true, + body: "@junior please review this PR context", + }) as Record; + const request = createGitHubRequest({ + eventType: "issue_comment", + payload, + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(200); + const commentPosts = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + entry.url.includes("/repos/acme/junior/issues/22/comments"), + ); + expect(commentPosts).toHaveLength(1); + expect(commentPosts[0]?.body).toMatchObject({ + body: "GitHub final reply", + }); + }); + + it("replies to explicit mentions in PR review comment threads", async () => { + const { bot } = await createGitHubBot(); + const waitUntilTasks: Array> = []; + const payload = githubReviewCommentWebhook({ + pullRequestNumber: 33, + reviewCommentId: 9001, + body: "@junior reply in this review thread", + }) as Record; + const request = createGitHubRequest({ + eventType: "pull_request_review_comment", + payload, + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(200); + const reviewReplyPosts = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + entry.url.includes("/repos/acme/junior/pulls/33/comments/9001/replies"), + ); + expect(reviewReplyPosts).toHaveLength(1); + expect(reviewReplyPosts[0]?.body).toMatchObject({ + body: "GitHub final reply", + }); + }); + + it("ignores untagged GitHub comments in V1", async () => { + const { bot, handledMessages } = await createGitHubBot(); + const waitUntilTasks: Array> = []; + const payload = githubIssueCommentWebhook({ + issueNumber: 44, + isPullRequest: false, + body: "can someone help with this issue?", + }) as Record; + const request = createGitHubRequest({ + eventType: "issue_comment", + payload, + }); + + const response = await handlePlatformWebhook( + request, + "github", + collectWaitUntil(waitUntilTasks), + bot, + ); + await flushWaitUntil(waitUntilTasks); + + expect(response.status).toBe(200); + expect(handledMessages).toHaveLength(0); + const postCalls = getCapturedGitHubApiCalls().filter( + (entry) => + entry.method === "POST" && + (entry.url.includes("/issues/") || entry.url.includes("/pulls/")), + ); + expect(postCalls).toHaveLength(0); + }); +}); diff --git a/packages/junior/tests/integration/slack-canvas-read.test.ts b/packages/junior/tests/integration/slack-canvas-read.test.ts index 400f8ed4..b6f98e9e 100644 --- a/packages/junior/tests/integration/slack-canvas-read.test.ts +++ b/packages/junior/tests/integration/slack-canvas-read.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createSlackCanvasReadTool } from "@/chat/tools/slack/canvas-tools"; +import { createSlackCanvasReadTool } from "@/chat/slack/tools/canvas-tools"; import { filesInfoOk } from "../fixtures/slack/factories/api"; import { getCapturedSlackApiCalls, diff --git a/packages/junior/tests/integration/slack-canvas-update.test.ts b/packages/junior/tests/integration/slack-canvas-update.test.ts index ad54a31f..51cd4a62 100644 --- a/packages/junior/tests/integration/slack-canvas-update.test.ts +++ b/packages/junior/tests/integration/slack-canvas-update.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createSlackCanvasUpdateTool } from "@/chat/tools/slack/canvas-tools"; +import { createSlackCanvasUpdateTool } from "@/chat/slack/tools/canvas-tools"; import type { ToolRuntimeContext, ToolState } from "@/chat/tools/types"; import { canvasesEditOk } from "../fixtures/slack/factories/api"; import { @@ -41,6 +41,7 @@ function createContext(userText: string): ToolRuntimeContext { canAddReactions: false, }, sandbox: {} as never, + toolProfile: "slack", }; } diff --git a/packages/junior/tests/integration/slack-canvases.test.ts b/packages/junior/tests/integration/slack-canvases.test.ts index c6f22f51..26166cba 100644 --- a/packages/junior/tests/integration/slack-canvases.test.ts +++ b/packages/junior/tests/integration/slack-canvases.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createCanvas } from "@/chat/tools/slack/canvases"; +import { createCanvas } from "@/chat/slack/tools/canvases"; import { canvasesAccessSetOk, canvasesCreateOk, diff --git a/packages/junior/tests/integration/slack-channel-tools.test.ts b/packages/junior/tests/integration/slack-channel-tools.test.ts index 64ed6133..81cad537 100644 --- a/packages/junior/tests/integration/slack-channel-tools.test.ts +++ b/packages/junior/tests/integration/slack-channel-tools.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { createSlackChannelListMessagesTool } from "@/chat/tools/slack/channel-list-messages"; -import { createSlackChannelPostMessageTool } from "@/chat/tools/slack/channel-post-message"; -import { createSlackMessageAddReactionTool } from "@/chat/tools/slack/message-add-reaction"; +import { createSlackChannelListMessagesTool } from "@/chat/slack/tools/channel-list-messages"; +import { createSlackChannelPostMessageTool } from "@/chat/slack/tools/channel-post-message"; +import { createSlackMessageAddReactionTool } from "@/chat/slack/tools/message-add-reaction"; import type { ToolRuntimeContext, ToolState } from "@/chat/tools/types"; import { chatGetPermalinkOk, @@ -50,6 +50,7 @@ function createContext(userText: string): ToolRuntimeContext { messageTs: "1700000000.321", userText, sandbox: {} as any, + toolProfile: "slack", }; } diff --git a/packages/junior/tests/integration/slack-list-create-update.test.ts b/packages/junior/tests/integration/slack-list-create-update.test.ts index 0178c310..e62a48e0 100644 --- a/packages/junior/tests/integration/slack-list-create-update.test.ts +++ b/packages/junior/tests/integration/slack-list-create-update.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createSlackListCreateTool } from "@/chat/tools/slack/list-tools"; -import { createSlackListUpdateItemTool } from "@/chat/tools/slack/list-tools"; +import { createSlackListCreateTool } from "@/chat/slack/tools/list-tools"; +import { createSlackListUpdateItemTool } from "@/chat/slack/tools/list-tools"; import type { ToolState } from "@/chat/tools/types"; import { slackListsCreateOk } from "../fixtures/slack/factories/api"; import { diff --git a/packages/junior/tests/integration/slack-list-tools.test.ts b/packages/junior/tests/integration/slack-list-tools.test.ts index c1f4ad5d..4db0e757 100644 --- a/packages/junior/tests/integration/slack-list-tools.test.ts +++ b/packages/junior/tests/integration/slack-list-tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createSlackListGetItemsTool } from "@/chat/tools/slack/list-tools"; +import { createSlackListGetItemsTool } from "@/chat/slack/tools/list-tools"; import type { ToolState } from "@/chat/tools/types"; import { slackListsItemsListPage } from "../fixtures/slack/factories/api"; import { diff --git a/packages/junior/tests/integration/slack-thread-read.test.ts b/packages/junior/tests/integration/slack-thread-read.test.ts index 3fa1c0e4..fe1c8339 100644 --- a/packages/junior/tests/integration/slack-thread-read.test.ts +++ b/packages/junior/tests/integration/slack-thread-read.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createSlackThreadReadTool } from "@/chat/tools/slack/thread-read"; +import { createSlackThreadReadTool } from "@/chat/slack/tools/thread-read"; import type { ToolRuntimeContext } from "@/chat/tools/types"; import { conversationsRepliesPage } from "../fixtures/slack/factories/api"; import { @@ -19,6 +19,7 @@ function createContext( canAddReactions: true, }, sandbox: {} as any, + toolProfile: "slack", ...overrides, }; } diff --git a/packages/junior/tests/integration/slack-user-lookup.test.ts b/packages/junior/tests/integration/slack-user-lookup.test.ts index d5a53e0c..66dae9e0 100644 --- a/packages/junior/tests/integration/slack-user-lookup.test.ts +++ b/packages/junior/tests/integration/slack-user-lookup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createSlackUserLookupTool } from "@/chat/tools/slack/user-lookup"; +import { createSlackUserLookupTool } from "@/chat/slack/tools/user-lookup"; import { usersInfoOk, usersListPage } from "../fixtures/slack/factories/api"; import { getCapturedSlackApiCalls, @@ -349,6 +349,7 @@ describe("slackUserLookup", () => { canAddReactions: true, }, sandbox: {} as any, + toolProfile: "slack", }, ); diff --git a/packages/junior/tests/integration/slack/assistant-context-canvas-routing.test.ts b/packages/junior/tests/integration/slack/assistant-context-canvas-routing.test.ts index b74325ab..17f3c3eb 100644 --- a/packages/junior/tests/integration/slack/assistant-context-canvas-routing.test.ts +++ b/packages/junior/tests/integration/slack/assistant-context-canvas-routing.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createCanvas } from "@/chat/tools/slack/canvases"; +import { createCanvas } from "@/chat/slack/tools/canvases"; import { canvasesAccessSetOk, canvasesCreateOk, diff --git a/packages/junior/tests/integration/tool-idempotency.test.ts b/packages/junior/tests/integration/tool-idempotency.test.ts index 9a1ea382..f953362f 100644 --- a/packages/junior/tests/integration/tool-idempotency.test.ts +++ b/packages/junior/tests/integration/tool-idempotency.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { createSlackCanvasCreateTool } from "@/chat/tools/slack/canvas-tools"; +import { createSlackCanvasCreateTool } from "@/chat/slack/tools/canvas-tools"; import { createOperationKey } from "@/chat/tools/idempotency"; -import { createSlackListAddItemsTool } from "@/chat/tools/slack/list-tools"; +import { createSlackListAddItemsTool } from "@/chat/slack/tools/list-tools"; import { SlackActionError } from "@/chat/slack/client"; import type { ToolState } from "@/chat/tools/types"; import { @@ -101,6 +101,7 @@ describe("tool idempotency", () => { canAddReactions: true, }, sandbox: noopSandbox, + toolProfile: "slack", }, state, ); @@ -158,6 +159,7 @@ describe("tool idempotency", () => { canAddReactions: true, }, sandbox: noopSandbox, + toolProfile: "slack", }, state, ); @@ -195,6 +197,7 @@ describe("tool idempotency", () => { canAddReactions: false, }, sandbox: noopSandbox, + toolProfile: "slack", }, state, ); @@ -270,6 +273,7 @@ describe("tool idempotency", () => { canAddReactions: true, }, sandbox: noopSandbox, + toolProfile: "slack", }, state, ); diff --git a/packages/junior/tests/msw/handlers/github-api.ts b/packages/junior/tests/msw/handlers/github-api.ts new file mode 100644 index 00000000..7b5e10e4 --- /dev/null +++ b/packages/junior/tests/msw/handlers/github-api.ts @@ -0,0 +1,101 @@ +import { http, HttpResponse } from "msw"; + +export interface CapturedGitHubApiCall { + body: unknown; + headers: Record; + method: string; + params: Record; + url: string; +} + +const capturedGitHubApiCalls: CapturedGitHubApiCall[] = []; +let nextCommentId = 10_000; + +function normalizeHeaders(headers: Headers): Record { + const normalized: Record = {}; + headers.forEach((value, key) => { + normalized[key.toLowerCase()] = value; + }); + return normalized; +} + +function buildGitHubCommentResponse(args: { body: string; id?: number }) { + const id = args.id ?? nextCommentId++; + const now = new Date().toISOString(); + const botLogin = process.env.GITHUB_BOT_USERNAME ?? "junior-bot"; + return { + id, + body: args.body, + html_url: `https://github.com/owner/repo/pull/1#issuecomment-${id}`, + created_at: now, + updated_at: now, + user: { + id: 987_654, + login: botLogin, + type: "Bot", + }, + }; +} + +export function getCapturedGitHubApiCalls(): CapturedGitHubApiCall[] { + return [...capturedGitHubApiCalls]; +} + +export function resetGitHubApiMockState(): void { + capturedGitHubApiCalls.length = 0; + nextCommentId = 10_000; +} + +export const githubApiHandlers = [ + http.get("https://api.github.com/user", async ({ request }) => { + capturedGitHubApiCalls.push({ + method: "GET", + url: request.url, + headers: normalizeHeaders(request.headers), + params: {}, + body: undefined, + }); + + return HttpResponse.json({ + id: 987_654, + login: process.env.GITHUB_BOT_USERNAME ?? "junior-bot", + type: "Bot", + }); + }), + + http.post( + "https://api.github.com/repos/:owner/:repo/issues/:issue_number/comments", + async ({ params, request }) => { + const payload = (await request.json()) as { body?: string }; + capturedGitHubApiCalls.push({ + method: "POST", + url: request.url, + headers: normalizeHeaders(request.headers), + params: params as Record, + body: payload, + }); + + return HttpResponse.json( + buildGitHubCommentResponse({ body: payload.body ?? "" }), + ); + }, + ), + + http.post( + "https://api.github.com/repos/:owner/:repo/pulls/:pull_number/comments/:comment_id/replies", + async ({ params, request }) => { + const payload = (await request.json()) as { body?: string }; + capturedGitHubApiCalls.push({ + method: "POST", + url: request.url, + headers: normalizeHeaders(request.headers), + params: params as Record, + body: payload, + }); + + return HttpResponse.json( + buildGitHubCommentResponse({ body: payload.body ?? "" }), + ); + }, + ), +]; diff --git a/packages/junior/tests/msw/server.ts b/packages/junior/tests/msw/server.ts index b7a6ec79..b7445c43 100644 --- a/packages/junior/tests/msw/server.ts +++ b/packages/junior/tests/msw/server.ts @@ -4,6 +4,7 @@ import { evalMcpAuthHandlers, } from "./handlers/eval-mcp-auth"; import { setupServer } from "msw/node"; +import { githubApiHandlers } from "./handlers/github-api"; import { slackApiHandlers } from "./handlers/slack-api"; import { slackWebhookHandlers } from "./handlers/slack-webhooks"; @@ -14,9 +15,14 @@ function isSlackHost(hostname: string): boolean { return hostname === "slack.com" || hostname === "files.slack.com"; } +function isGitHubHost(hostname: string): boolean { + return hostname === "api.github.com"; +} + function requiresMockedHandling(hostname: string): boolean { return ( isSlackHost(hostname) || + isGitHubHost(hostname) || hostname === EVAL_MCP_AUTH_HOSTNAME || hostname === EVAL_OAUTH_HOSTNAME ); @@ -34,6 +40,7 @@ export function enforceUnhandledSlackRequestFailure(request: Request): void { } export const mswServer = setupServer( + ...githubApiHandlers, ...slackApiHandlers, ...slackWebhookHandlers, ...evalMcpAuthHandlers, diff --git a/packages/junior/tests/msw/setup.ts b/packages/junior/tests/msw/setup.ts index 7b13d344..7130fb2c 100644 --- a/packages/junior/tests/msw/setup.ts +++ b/packages/junior/tests/msw/setup.ts @@ -1,6 +1,7 @@ import { resetEvalOAuthMockState } from "./handlers/eval-oauth"; import { resetEvalMcpAuthMockState } from "./handlers/eval-mcp-auth"; import { afterAll, afterEach, beforeAll } from "vitest"; +import { resetGitHubApiMockState } from "./handlers/github-api"; import { resetSlackApiMockState } from "./handlers/slack-api"; import { enforceUnhandledSlackRequestFailure, mswServer } from "./server"; @@ -29,6 +30,7 @@ afterEach(() => { mswServer.resetHandlers(); resetEvalOAuthMockState(); resetEvalMcpAuthMockState(); + resetGitHubApiMockState(); resetSlackApiMockState(); }); diff --git a/packages/junior/tests/unit/app.test.ts b/packages/junior/tests/unit/app.test.ts new file mode 100644 index 00000000..ad6b8f47 --- /dev/null +++ b/packages/junior/tests/unit/app.test.ts @@ -0,0 +1,145 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "@/app"; + +const originalExtraPluginRoots = process.env.JUNIOR_EXTRA_PLUGIN_ROOTS; + +function useFixturePlugins(): void { + process.env.JUNIOR_EXTRA_PLUGIN_ROOTS = path.resolve( + "tests/fixtures/plugins", + ); +} + +afterEach(() => { + if (originalExtraPluginRoots === undefined) { + delete process.env.JUNIOR_EXTRA_PLUGIN_ROOTS; + } else { + process.env.JUNIOR_EXTRA_PLUGIN_ROOTS = originalExtraPluginRoots; + } + vi.doUnmock("#junior/config"); +}); + +describe("createApp", () => { + it("accepts chat platform enablement in the app initializer", async () => { + await expect( + createApp({ enabledPlatforms: ["github"] }), + ).resolves.toBeDefined(); + }); + + it("rejects unsupported chat platforms from the app initializer", async () => { + await expect( + createApp({ enabledPlatforms: ["email" as never] }), + ).rejects.toThrow("enabledPlatforms must contain only: slack, github"); + }); + + it("accepts per-platform plugin and skill configuration", async () => { + useFixturePlugins(); + + const app = await createApp({ + platforms: { + github: { + plugins: ["eval-auth"], + skills: ["eval-auth"], + configDefaults: { + "github.repo": "acme/junior", + }, + }, + }, + }); + + expect( + ( + await app.request("/api/internal/turn-resume", { + method: "POST", + }) + ).status, + ).toBe(404); + }); + + it("accepts an explicit platform with no plugins or skills", async () => { + const app = await createApp({ + platforms: { + github: { + plugins: [], + skills: [], + }, + }, + }); + + expect( + ( + await app.request("/api/internal/turn-resume", { + method: "POST", + }) + ).status, + ).toBe(404); + }); + + it("normalizes platform keys before resolving their config", async () => { + const app = await createApp({ + platforms: { + GitHub: { + plugins: [], + skills: [], + }, + } as never, + }); + + expect( + ( + await app.request("/api/internal/turn-resume", { + method: "POST", + }) + ).status, + ).toBe(404); + }); + + it("lets runtime platform options override build-time platform config", async () => { + vi.doMock("#junior/config", () => ({ + pluginPackages: [], + enabledPlatforms: undefined, + platforms: { + slack: { + plugins: ["missing-provider"], + }, + }, + })); + + await expect( + createApp({ enabledPlatforms: ["github"] }), + ).resolves.toBeDefined(); + }); + + it("rejects unknown platform plugin names", async () => { + useFixturePlugins(); + + await expect( + createApp({ + platforms: { + github: { + plugins: ["missing-provider"], + }, + }, + }), + ).rejects.toThrow( + 'platforms.github.plugins contains unknown plugin "missing-provider"', + ); + }); + + it("rejects skills owned by disabled platform plugins", async () => { + useFixturePlugins(); + + await expect( + createApp({ + platforms: { + github: { + plugins: ["eval-oauth"], + skills: ["eval-auth"], + }, + }, + }), + ).rejects.toThrow( + 'platforms.github.skills includes "eval-auth" from plugin "eval-auth"', + ); + }); +}); diff --git a/packages/junior/tests/unit/app/production-platforms.test.ts b/packages/junior/tests/unit/app/production-platforms.test.ts new file mode 100644 index 00000000..de08aaf1 --- /dev/null +++ b/packages/junior/tests/unit/app/production-platforms.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const githubEnvKeys = [ + "GITHUB_APP_ID", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_INSTALLATION_ID", + "GITHUB_WEBHOOK_SECRET", + "GITHUB_BOT_USERNAME", +] as const; + +describe("production platform wiring", () => { + const originalEnv = Object.fromEntries( + githubEnvKeys.map((key) => [key, process.env[key]]), + ); + + afterEach(() => { + for (const key of githubEnvKeys) { + const original = originalEnv[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + vi.resetModules(); + }); + + it("validates the normalized GitHub mention target before creating the adapter", async () => { + process.env.GITHUB_APP_ID = "123"; + process.env.GITHUB_APP_PRIVATE_KEY = "private-key"; + process.env.GITHUB_INSTALLATION_ID = "456"; + process.env.GITHUB_WEBHOOK_SECRET = "secret"; + process.env.GITHUB_BOT_USERNAME = "[bot]"; + vi.resetModules(); + + const { createProductionBotResolver } = + await import("@/chat/app/production"); + const getBot = createProductionBotResolver({ + enabledPlatforms: ["github"], + }); + + expect(() => getBot()).toThrow( + "GitHub adapter requires GITHUB_BOT_USERNAME when GitHub webhook support is enabled", + ); + }); +}); diff --git a/packages/junior/tests/unit/build/injected-package-sync.test.ts b/packages/junior/tests/unit/build/injected-package-sync.test.ts index cec4f8b6..950e4f74 100644 --- a/packages/junior/tests/unit/build/injected-package-sync.test.ts +++ b/packages/junior/tests/unit/build/injected-package-sync.test.ts @@ -1,12 +1,11 @@ import fs from "node:fs"; -import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -const require = createRequire(import.meta.url); const { isLinkedDirectory, linkDirectory, resolveInjectedPackageDir } = - require("../../../../../scripts/lib/injected-package-sync.mjs") as { + // @ts-expect-error - runtime script module has no published .d.ts file. + (await import("../../../../../scripts/lib/injected-package-sync.mjs")) as { isLinkedDirectory: (sourceDir: string, targetDir: string) => boolean; linkDirectory: (sourceDir: string, targetDir: string) => void; resolveInjectedPackageDir: ( diff --git a/packages/junior/tests/unit/build/virtual-config.test.ts b/packages/junior/tests/unit/build/virtual-config.test.ts new file mode 100644 index 00000000..e0ec4e20 --- /dev/null +++ b/packages/junior/tests/unit/build/virtual-config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { injectVirtualConfig } from "@/build/virtual-config"; + +describe("virtual Junior config", () => { + it("exposes plugin packages and enabled chat platforms", () => { + const nitro = { + options: { + virtual: {}, + }, + } as unknown as Parameters[0]; + + injectVirtualConfig(nitro, { + enabledPlatforms: ["github"], + pluginPackages: ["@sentry/junior-github"], + }); + + expect(nitro.options.virtual["#junior/config"]).toBe( + [ + 'export const pluginPackages = ["@sentry/junior-github"];', + 'export const enabledPlatforms = ["github"];', + "export const platforms = undefined;", + ].join("\n"), + ); + }); + + it("exposes per-platform plugin configuration", () => { + const nitro = { + options: { + virtual: {}, + }, + } as unknown as Parameters[0]; + + injectVirtualConfig(nitro, { + pluginPackages: [], + platforms: { + github: { + plugins: ["sentry"], + skills: [], + }, + }, + }); + + expect(nitro.options.virtual["#junior/config"]).toBe( + [ + "export const pluginPackages = [];", + "export const enabledPlatforms = undefined;", + 'export const platforms = {"github":{"plugins":["sentry"],"skills":[]}};', + ].join("\n"), + ); + }); +}); diff --git a/packages/junior/tests/unit/config/chat-config.test.ts b/packages/junior/tests/unit/config/chat-config.test.ts index d29b6eab..8a0cbd3c 100644 --- a/packages/junior/tests/unit/config/chat-config.test.ts +++ b/packages/junior/tests/unit/config/chat-config.test.ts @@ -177,4 +177,34 @@ describe("chat config", () => { const { botConfig } = await loadConfig(); expect(botConfig.turnTimeoutMs).toBe(480000); }); + + it("reads explicit GitHub adapter env mapping", async () => { + process.env.GITHUB_APP_ID = "123456"; + process.env.GITHUB_APP_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n..."; + process.env.GITHUB_INSTALLATION_ID = "999"; + process.env.GITHUB_WEBHOOK_SECRET = "webhook-secret"; + process.env.GITHUB_BOT_USERNAME = "junior-bot"; + + const { + getGitHubAppId, + getGitHubAppPrivateKey, + getGitHubInstallationId, + getGitHubWebhookSecret, + getGitHubBotUsername, + } = await loadConfig(); + + expect(getGitHubAppId()).toBe("123456"); + expect(getGitHubAppPrivateKey()).toBe("-----BEGIN PRIVATE KEY-----\n..."); + expect(getGitHubInstallationId()).toBe(999); + expect(getGitHubWebhookSecret()).toBe("webhook-secret"); + expect(getGitHubBotUsername()).toBe("junior-bot"); + }); + + it("throws when GITHUB_INSTALLATION_ID is not a positive integer", async () => { + process.env.GITHUB_INSTALLATION_ID = "not-a-number"; + + await expect(loadConfig()).rejects.toThrow( + "GITHUB_INSTALLATION_ID must be a positive integer", + ); + }); }); diff --git a/packages/junior/tests/unit/config/platforms.test.ts b/packages/junior/tests/unit/config/platforms.test.ts new file mode 100644 index 00000000..cb2b7f48 --- /dev/null +++ b/packages/junior/tests/unit/config/platforms.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { resolveEnabledChatPlatforms } from "@/chat/platforms"; + +describe("chat platform config", () => { + it("enables Slack by default", () => { + expect(resolveEnabledChatPlatforms(undefined)).toEqual(["slack"]); + }); + + it("normalizes explicit enabled chat platforms", () => { + expect( + resolveEnabledChatPlatforms(["github", " slack ", "github"]), + ).toEqual(["github", "slack"]); + }); + + it("throws when enabled chat platforms contain an unknown platform", () => { + expect(() => resolveEnabledChatPlatforms(["slack", "email"])).toThrow( + "enabledPlatforms must contain only: slack, github", + ); + }); +}); diff --git a/packages/junior/tests/unit/github/mention.test.ts b/packages/junior/tests/unit/github/mention.test.ts new file mode 100644 index 00000000..0f38989a --- /dev/null +++ b/packages/junior/tests/unit/github/mention.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + getGitHubMentionTargets, + normalizeGitHubMentionTarget, +} from "@/chat/github/mention"; +import { stripLeadingBotMention } from "@/chat/runtime/thread-context"; + +describe("GitHub mention handling", () => { + it("normalizes GitHub bot handles into mention targets", () => { + expect(normalizeGitHubMentionTarget("@junior[bot]")).toBe("junior"); + expect(normalizeGitHubMentionTarget(" junior-bot ")).toBe("junior-bot"); + }); + + it("keeps configured and GitHub App bot mention variants", () => { + expect(getGitHubMentionTargets("junior")).toEqual([ + "junior", + "junior[bot]", + ]); + expect(getGitHubMentionTargets("@junior[bot]")).toEqual([ + "junior[bot]", + "junior", + ]); + }); + + it("does not invent a bot suffix target from blank config", () => { + expect(getGitHubMentionTargets(" ")).toEqual([]); + }); + + it("strips the configured GitHub mention target from the current turn text", () => { + expect( + stripLeadingBotMention("@junior-bot: inspect this PR", { + botUserName: "junior-bot", + }), + ).toBe("inspect this PR"); + expect( + stripLeadingBotMention("@junior[bot] inspect this PR", { + botUserName: "junior[bot]", + }), + ).toBe("inspect this PR"); + }); + + it("strips GitHub App suffix variants when config stores the normalized name", () => { + const stripped = getGitHubMentionTargets("junior").reduce( + (next, botUserName) => + stripLeadingBotMention(next, { + botUserName, + stripLeadingSlackMentionToken: false, + }), + "@junior[bot] inspect this PR", + ); + + expect(stripped).toBe("inspect this PR"); + }); + + it("does not strip a longer GitHub handle that only shares a prefix", () => { + expect( + stripLeadingBotMention("@junior-bot inspect this PR", { + botUserName: "junior", + }), + ).toBe("@junior-bot inspect this PR"); + }); +}); diff --git a/packages/junior/tests/unit/handlers/handlers-webhooks-lazy-load.test.ts b/packages/junior/tests/unit/handlers/handlers-webhooks-lazy-load.test.ts index 9b536f8c..c469edb0 100644 --- a/packages/junior/tests/unit/handlers/handlers-webhooks-lazy-load.test.ts +++ b/packages/junior/tests/unit/handlers/handlers-webhooks-lazy-load.test.ts @@ -26,6 +26,6 @@ describe("handlers webhooks module loading", () => { it("loads without requiring runtime env on module load", async () => { const mod = await import("@/handlers/webhooks"); - expect(typeof mod.POST).toBe("function"); + expect(typeof mod.handlePlatformWebhook).toBe("function"); }, 15_000); }); diff --git a/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts b/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts index 99767701..49c13c4b 100644 --- a/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts +++ b/packages/junior/tests/unit/handlers/sandbox-egress-proxy.test.ts @@ -81,14 +81,23 @@ const REQUESTER_ID = "U123"; async function authorizeSandboxEgress( requesterId = REQUESTER_ID, + providerNames?: readonly string[], ): Promise { await upsertSandboxEgressSession({ egressId: EGRESS_ID, + providerNames, requesterId, ttlMs: 60_000, }); } +async function authorizeHostSandboxEgress(): Promise { + await upsertSandboxEgressSession({ + egressId: EGRESS_ID, + ttlMs: 60_000, + }); +} + function mockSentryLease(domain = "sentry.io", token = "sentry-token"): void { issueProviderCredentialLeaseMock.mockResolvedValue({ id: "lease-1", @@ -303,6 +312,33 @@ describe("sandbox egress proxy", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("can issue host-scoped credential leases without a requester", async () => { + await authorizeHostSandboxEgress(); + mockSentryLease(); + + const response = await proxy(egressRequest()); + + expect(response.status).toBe(200); + expect(issueProviderCredentialLeaseMock).toHaveBeenCalledWith({ + provider: "sentry", + requesterId: undefined, + reason: "sandbox-egress:sentry", + }); + }); + + it("rejects providers outside the sandbox session allowlist", async () => { + await authorizeSandboxEgress(REQUESTER_ID, ["github"]); + mockSentryLease(); + + const response = await proxy(egressRequest()); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Provider is not enabled for this sandbox session", + }); + expect(issueProviderCredentialLeaseMock).not.toHaveBeenCalled(); + }); + it("scopes cached credential leases to the requester", async () => { await authorizeSandboxEgress(); issueProviderCredentialLeaseMock diff --git a/packages/junior/tests/unit/plugins/plugin-registry.test.ts b/packages/junior/tests/unit/plugins/plugin-registry.test.ts index 729adc5e..a5fdc38c 100644 --- a/packages/junior/tests/unit/plugins/plugin-registry.test.ts +++ b/packages/junior/tests/unit/plugins/plugin-registry.test.ts @@ -87,6 +87,9 @@ describe("plugin registry", () => { expect(registry.getPluginProviders()).toHaveLength(1); expect(registry.getPluginProviders()[0]?.manifest.name).toBe("demo"); + const providers = registry.getPluginProviders(); + providers.pop(); + expect(registry.getPluginProviders()).toHaveLength(1); expect(registry.getPluginSkillRoots()).toContain(skillsRoot); expect(registry.isPluginProvider("demo")).toBe(true); }); diff --git a/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts b/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts index acec3c68..a8f06801 100644 --- a/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts +++ b/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts @@ -1,8 +1,9 @@ import { Buffer } from "node:buffer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { promptAborted } = vi.hoisted(() => ({ +const { promptAborted, sandboxExecutorOptions } = vi.hoisted(() => ({ promptAborted: { value: false }, + sandboxExecutorOptions: { value: [] as Array> }, })); vi.mock("@mariozechner/pi-agent-core", () => { @@ -122,25 +123,28 @@ vi.mock("@/chat/runtime/dev-agent-trace", () => ({ })); vi.mock("@/chat/sandbox/sandbox", () => ({ - createSandboxExecutor: () => ({ - configureSkills: () => undefined, - configureReferenceFiles: () => undefined, - createSandbox: async () => ({ - readFileToBuffer: async () => Buffer.from("", "utf8"), - runCommand: async () => ({ - stdout: "", - stderr: "", - exitCode: 0, + createSandboxExecutor: (options: Record) => { + sandboxExecutorOptions.value.push(options); + return { + configureSkills: () => undefined, + configureReferenceFiles: () => undefined, + createSandbox: async () => ({ + readFileToBuffer: async () => Buffer.from("", "utf8"), + runCommand: async () => ({ + stdout: "", + stderr: "", + exitCode: 0, + }), }), - }), - canExecute: () => false, - execute: async () => { - throw new Error("sandbox executor should not execute in this test"); - }, - getSandboxId: () => undefined, - getDependencyProfileHash: () => undefined, - dispose: async () => undefined, - }), + canExecute: () => false, + execute: async () => { + throw new Error("sandbox executor should not execute in this test"); + }, + getSandboxId: () => undefined, + getDependencyProfileHash: () => undefined, + dispose: async () => undefined, + }; + }, })); vi.mock("@/chat/plugins/registry", async (importOriginal) => ({ @@ -160,10 +164,12 @@ import { generateAssistantReply } from "@/chat/respond"; import { isRetryableTurnError } from "@/chat/runtime/turn"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { getAgentTurnSessionCheckpoint } from "@/chat/state/turn-session-store"; +import { GITHUB_COMMENT_SURFACE } from "@/chat/surface"; describe("generateAssistantReply timeout resume", () => { beforeEach(async () => { promptAborted.value = false; + sandboxExecutorOptions.value = []; process.env.JUNIOR_STATE_ADAPTER = "memory"; await disconnectStateAdapter(); vi.useFakeTimers(); @@ -252,4 +258,45 @@ describe("generateAssistantReply timeout resume", () => { ]), ); }); + + it("does not use GitHub requester IDs for user-scoped auth", async () => { + const replyPromise = generateAssistantReply("help me", { + surface: GITHUB_COMMENT_SURFACE, + requester: { userId: "github-user" }, + correlation: { + conversationId: "github:conversation-auth", + turnId: "github-turn-auth", + runId: "github-run-auth", + }, + }); + + await vi.advanceTimersByTimeAsync(10_000); + await replyPromise; + + expect(sandboxExecutorOptions.value[0]?.credentialEgress).toMatchObject({ + requesterId: undefined, + }); + }); + + it("does not park GitHub comment turns into the Slack timeout resume queue", async () => { + const replyPromise = generateAssistantReply("help me", { + surface: GITHUB_COMMENT_SURFACE, + requester: { userId: "github-user" }, + correlation: { + conversationId: "github:conversation-1", + turnId: "github-turn-1", + runId: "github-run-1", + }, + }); + + await vi.advanceTimersByTimeAsync(10_000); + const reply = await replyPromise; + + expect(promptAborted.value).toBe(true); + expect(reply.diagnostics.outcome).toBe("provider_error"); + expect(reply.text).toContain("Error:"); + await expect( + getAgentTurnSessionCheckpoint("github:conversation-1", "github-turn-1"), + ).resolves.toBeUndefined(); + }); }); diff --git a/packages/junior/tests/unit/skills-plugin-provider.test.ts b/packages/junior/tests/unit/skills-plugin-provider.test.ts index ebee0d2c..e241c1ec 100644 --- a/packages/junior/tests/unit/skills-plugin-provider.test.ts +++ b/packages/junior/tests/unit/skills-plugin-provider.test.ts @@ -86,6 +86,28 @@ describe("discoverSkills plugin ownership", () => { expect( skills.find((skill) => skill.name === "notes")?.pluginProvider, ).toBeUndefined(); + + await expect( + discoverSkills({ allowedPluginNames: ["other"] }), + ).resolves.toEqual([ + expect.objectContaining({ + name: "notes", + }), + ]); + await expect( + discoverSkills({ + allowedPluginNames: ["demo"], + allowedSkillNames: ["triage"], + }), + ).resolves.toEqual([ + expect.objectContaining({ + name: "triage", + pluginProvider: "demo", + }), + ]); + await expect( + discoverSkills({ allowedPluginNames: ["demo"], allowedSkillNames: [] }), + ).resolves.toEqual([]); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/packages/junior/tests/unit/slack/slack-canvas-markdown.test.ts b/packages/junior/tests/unit/slack/slack-canvas-markdown.test.ts index 21bde97a..18009d85 100644 --- a/packages/junior/tests/unit/slack/slack-canvas-markdown.test.ts +++ b/packages/junior/tests/unit/slack/slack-canvas-markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeCanvasMarkdown } from "@/chat/tools/slack/canvases"; +import { normalizeCanvasMarkdown } from "@/chat/slack/tools/canvases"; describe("normalizeCanvasMarkdown", () => { it("downgrades unsupported heading depth to h3", () => { diff --git a/packages/junior/tests/unit/slack/slack-lists.test.ts b/packages/junior/tests/unit/slack/slack-lists.test.ts index 2e7c1708..d61bc9f3 100644 --- a/packages/junior/tests/unit/slack/slack-lists.test.ts +++ b/packages/junior/tests/unit/slack/slack-lists.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { inferListColumnMap } from "@/chat/tools/slack/lists"; +import { inferListColumnMap } from "@/chat/slack/tools/lists"; describe("inferListColumnMap", () => { it("detects canonical todo columns", () => { diff --git a/packages/junior/tests/unit/slack/slack-message-add-reaction-tool.test.ts b/packages/junior/tests/unit/slack/slack-message-add-reaction-tool.test.ts index 7c9f844b..073fdb6f 100644 --- a/packages/junior/tests/unit/slack/slack-message-add-reaction-tool.test.ts +++ b/packages/junior/tests/unit/slack/slack-message-add-reaction-tool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { createSlackMessageAddReactionTool } from "@/chat/tools/slack/message-add-reaction"; +import { createSlackMessageAddReactionTool } from "@/chat/slack/tools/message-add-reaction"; const addReactionToMessage = vi.fn(); @@ -31,6 +31,7 @@ describe("slackMessageAddReaction tool", () => { }, messageTs: "1700000000.100", sandbox: {} as any, + toolProfile: "slack", }, createState() as any, ); @@ -60,6 +61,7 @@ describe("slackMessageAddReaction tool", () => { }, messageTs: "1700000000.100", sandbox: {} as any, + toolProfile: "slack", }, createState() as any, ); @@ -94,6 +96,7 @@ describe("slackMessageAddReaction tool", () => { }, messageTs: "1700000000.100", sandbox: {} as any, + toolProfile: "slack", }, createState() as any, ); diff --git a/packages/junior/tests/unit/slack/tool-registration.test.ts b/packages/junior/tests/unit/slack/tool-registration.test.ts index 916b8686..0920f550 100644 --- a/packages/junior/tests/unit/slack/tool-registration.test.ts +++ b/packages/junior/tests/unit/slack/tool-registration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createTools } from "@/chat/tools"; -import { resolveChannelCapabilities } from "@/chat/tools/channel-capabilities"; +import { resolveChannelCapabilities } from "@/chat/slack/tools/channel-capabilities"; const noopSandbox = {} as any; @@ -9,6 +9,7 @@ function ctx(channelId?: string) { channelId, channelCapabilities: resolveChannelCapabilities(channelId), sandbox: noopSandbox, + toolProfile: "slack" as const, }; } diff --git a/packages/junior/tests/unit/tools/channel-capabilities.test.ts b/packages/junior/tests/unit/tools/channel-capabilities.test.ts index a16a4733..27519b63 100644 --- a/packages/junior/tests/unit/tools/channel-capabilities.test.ts +++ b/packages/junior/tests/unit/tools/channel-capabilities.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveChannelCapabilities } from "@/chat/tools/channel-capabilities"; +import { resolveChannelCapabilities } from "@/chat/slack/tools/channel-capabilities"; describe("resolveChannelCapabilities", () => { it.each([ diff --git a/packages/junior/tests/unit/tools/slack-canvas-id.test.ts b/packages/junior/tests/unit/tools/slack-canvas-id.test.ts index 58c438ae..46a61c2d 100644 --- a/packages/junior/tests/unit/tools/slack-canvas-id.test.ts +++ b/packages/junior/tests/unit/tools/slack-canvas-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { extractCanvasId } from "@/chat/tools/slack/canvases"; +import { extractCanvasId } from "@/chat/slack/tools/canvases"; describe("extractCanvasId", () => { it("returns an uppercased F-prefixed ID as-is", () => { diff --git a/packages/junior/tests/unit/tools/slack/parse-slack-url.test.ts b/packages/junior/tests/unit/tools/slack/parse-slack-url.test.ts index c9226cdb..37aff954 100644 --- a/packages/junior/tests/unit/tools/slack/parse-slack-url.test.ts +++ b/packages/junior/tests/unit/tools/slack/parse-slack-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseSlackMessageReference } from "@/chat/tools/slack/slack-message-url"; +import { parseSlackMessageReference } from "@/chat/slack/tools/slack-message-url"; describe("parseSlackMessageReference", () => { it("parses a plain archive URL", () => { diff --git a/packages/junior/tests/unit/tools/tool-profile.test.ts b/packages/junior/tests/unit/tools/tool-profile.test.ts new file mode 100644 index 00000000..5243f1a7 --- /dev/null +++ b/packages/junior/tests/unit/tools/tool-profile.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createTools } from "@/chat/tools"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; + +function context( + toolProfile: ToolRuntimeContext["toolProfile"], +): ToolRuntimeContext { + return { + channelId: "C123", + channelCapabilities: { + canCreateCanvas: true, + canPostToChannel: true, + canAddReactions: true, + }, + toolProfile, + sandbox: {} as ToolRuntimeContext["sandbox"], + }; +} + +describe("tool profiles", () => { + it("registers Slack tools for the slack profile", () => { + const tools = createTools([], {}, context("slack")); + expect(tools).toHaveProperty("slackCanvasRead"); + expect(tools).toHaveProperty("slackCanvasUpdate"); + expect(tools).toHaveProperty("slackThreadRead"); + expect(tools).toHaveProperty("slackUserLookup"); + expect(tools).toHaveProperty("slackMessageAddReaction"); + }); + + it("excludes Slack-only tools for the github-comment profile", () => { + const tools = createTools([], {}, context("github-comment")); + expect(tools).not.toHaveProperty("slackCanvasRead"); + expect(tools).not.toHaveProperty("slackCanvasUpdate"); + expect(tools).not.toHaveProperty("slackThreadRead"); + expect(tools).not.toHaveProperty("slackUserLookup"); + expect(tools).not.toHaveProperty("slackListCreate"); + expect(tools).not.toHaveProperty("slackChannelPostMessage"); + expect(tools).not.toHaveProperty("slackMessageAddReaction"); + }); +}); diff --git a/packages/junior/tsup.config.ts b/packages/junior/tsup.config.ts index d1bc2a8a..64079c38 100644 --- a/packages/junior/tsup.config.ts +++ b/packages/junior/tsup.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ "@sentry/node", // All runtime npm dependencies stay external "@ai-sdk/gateway", + "@chat-adapter/github", "@chat-adapter/slack", "@chat-adapter/state-memory", "@chat-adapter/state-redis", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef3d63a6..8d223eb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: "@ai-sdk/gateway": specifier: ^3.0.110 version: 3.0.110(zod@4.4.3) + "@chat-adapter/github": + specifier: 4.28.1 + version: 4.28.1 "@chat-adapter/slack": specifier: 4.28.1 version: 4.28.1 @@ -666,6 +669,12 @@ packages: } engines: { node: ">=18" } + "@chat-adapter/github@4.28.1": + resolution: + { + integrity: sha512-pCrQ//ufSOec3vcu2kG4iR3FxLdmzcCIZ5zYINbhE6SJbDHm2yfarYU4cLiY7rcL5Z+CwMosXLBRJcjO52iQZQ==, + } + "@chat-adapter/shared@4.28.1": resolution: { @@ -2051,6 +2060,136 @@ packages: } engines: { node: ">= 8" } + "@octokit/auth-app@8.2.0": + resolution: + { + integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==, + } + engines: { node: ">= 20" } + + "@octokit/auth-oauth-app@9.0.3": + resolution: + { + integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==, + } + engines: { node: ">= 20" } + + "@octokit/auth-oauth-device@8.0.3": + resolution: + { + integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==, + } + engines: { node: ">= 20" } + + "@octokit/auth-oauth-user@6.0.2": + resolution: + { + integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==, + } + engines: { node: ">= 20" } + + "@octokit/auth-token@6.0.0": + resolution: + { + integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==, + } + engines: { node: ">= 20" } + + "@octokit/core@7.0.6": + resolution: + { + integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==, + } + engines: { node: ">= 20" } + + "@octokit/endpoint@11.0.3": + resolution: + { + integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==, + } + engines: { node: ">= 20" } + + "@octokit/graphql@9.0.3": + resolution: + { + integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==, + } + engines: { node: ">= 20" } + + "@octokit/oauth-authorization-url@8.0.0": + resolution: + { + integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==, + } + engines: { node: ">= 20" } + + "@octokit/oauth-methods@6.0.2": + resolution: + { + integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==, + } + engines: { node: ">= 20" } + + "@octokit/openapi-types@27.0.0": + resolution: + { + integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==, + } + + "@octokit/plugin-paginate-rest@14.0.0": + resolution: + { + integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==, + } + engines: { node: ">= 20" } + peerDependencies: + "@octokit/core": ">=6" + + "@octokit/plugin-request-log@6.0.0": + resolution: + { + integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==, + } + engines: { node: ">= 20" } + peerDependencies: + "@octokit/core": ">=6" + + "@octokit/plugin-rest-endpoint-methods@17.0.0": + resolution: + { + integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==, + } + engines: { node: ">= 20" } + peerDependencies: + "@octokit/core": ">=6" + + "@octokit/request-error@7.1.0": + resolution: + { + integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==, + } + engines: { node: ">= 20" } + + "@octokit/request@10.0.9": + resolution: + { + integrity: sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==, + } + engines: { node: ">= 20" } + + "@octokit/rest@22.0.1": + resolution: + { + integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==, + } + engines: { node: ">= 20" } + + "@octokit/types@16.0.0": + resolution: + { + integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==, + } + "@open-draft/deferred-promise@2.2.0": resolution: { @@ -4675,16 +4814,16 @@ packages: integrity: sha512-dgiLWA3l+4IFk1jg65aABfmRqO2eJdk6vZsNvONhD8Lkpn8i1WrGJ0ggVjD7I/MR9rNxSs4zyfIgpKYv5FFqWw==, } - "@vercel/sandbox@2.0.0-beta.19": + "@vercel/sandbox@1.9.0": resolution: { - integrity: sha512-OH9DkzF3TP1wEBEBy1BHRPQHrUKNjSix8a3gTUOqouKJ0KJxzHNT8PFy+7xMx6MuEZ51cuWtnnesfo0j5TL3LA==, + integrity: sha512-zgr1ad0tkT1xZn/8Vxo60wOUOLqMAVGo4WqJQ8/UDcUtWynNJsBjI2tiMdWZrAo9EKH1MIqEzJNkcclF0UT1EQ==, } - "@vercel/sandbox@1.9.0": + "@vercel/sandbox@2.0.0-beta.19": resolution: { - integrity: sha512-zgr1ad0tkT1xZn/8Vxo60wOUOLqMAVGo4WqJQ8/UDcUtWynNJsBjI2tiMdWZrAo9EKH1MIqEzJNkcclF0UT1EQ==, + integrity: sha512-OH9DkzF3TP1wEBEBy1BHRPQHrUKNjSix8a3gTUOqouKJ0KJxzHNT8PFy+7xMx6MuEZ51cuWtnnesfo0j5TL3LA==, } "@vercel/static-build@2.9.15": @@ -5189,6 +5328,12 @@ packages: integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==, } + before-after-hook@4.0.0: + resolution: + { + integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==, + } + bignumber.js@9.3.1: resolution: { @@ -5611,6 +5756,13 @@ packages: } engines: { node: ">= 0.6" } + content-type@2.0.0: + resolution: + { + integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==, + } + engines: { node: ">=18" } + convert-hrtime@3.0.0: resolution: { @@ -6360,6 +6512,12 @@ packages: integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, } + fast-content-type-parse@3.0.0: + resolution: + { + integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==, + } + fast-deep-equal@3.1.3: resolution: { @@ -7330,6 +7488,12 @@ packages: integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, } + json-with-bigint@3.5.8: + resolution: + { + integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==, + } + json5@2.2.3: resolution: { @@ -10134,6 +10298,13 @@ packages: } engines: { node: ">=8.0" } + toad-cache@3.7.0: + resolution: + { + integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==, + } + engines: { node: ">=12" } + toidentifier@1.0.0: resolution: { @@ -10507,6 +10678,18 @@ packages: integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==, } + universal-github-app-jwt@2.2.2: + resolution: + { + integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==, + } + + universal-user-agent@7.0.3: + resolution: + { + integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==, + } + universalify@2.0.1: resolution: { @@ -11902,6 +12085,15 @@ snapshots: dependencies: fontkitten: 1.0.3 + "@chat-adapter/github@4.28.1": + dependencies: + "@chat-adapter/shared": 4.28.1 + "@octokit/auth-app": 8.2.0 + "@octokit/rest": 22.0.1 + chat: 4.28.1 + transitivePeerDependencies: + - supports-color + "@chat-adapter/shared@4.28.1": dependencies: chat: 4.28.1 @@ -12619,6 +12811,113 @@ snapshots: "@nodelib/fs.scandir": 2.1.5 fastq: 1.20.1 + "@octokit/auth-app@8.2.0": + dependencies: + "@octokit/auth-oauth-app": 9.0.3 + "@octokit/auth-oauth-user": 6.0.2 + "@octokit/request": 10.0.9 + "@octokit/request-error": 7.1.0 + "@octokit/types": 16.0.0 + toad-cache: 3.7.0 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + "@octokit/auth-oauth-app@9.0.3": + dependencies: + "@octokit/auth-oauth-device": 8.0.3 + "@octokit/auth-oauth-user": 6.0.2 + "@octokit/request": 10.0.9 + "@octokit/types": 16.0.0 + universal-user-agent: 7.0.3 + + "@octokit/auth-oauth-device@8.0.3": + dependencies: + "@octokit/oauth-methods": 6.0.2 + "@octokit/request": 10.0.9 + "@octokit/types": 16.0.0 + universal-user-agent: 7.0.3 + + "@octokit/auth-oauth-user@6.0.2": + dependencies: + "@octokit/auth-oauth-device": 8.0.3 + "@octokit/oauth-methods": 6.0.2 + "@octokit/request": 10.0.9 + "@octokit/types": 16.0.0 + universal-user-agent: 7.0.3 + + "@octokit/auth-token@6.0.0": {} + + "@octokit/core@7.0.6": + dependencies: + "@octokit/auth-token": 6.0.0 + "@octokit/graphql": 9.0.3 + "@octokit/request": 10.0.9 + "@octokit/request-error": 7.1.0 + "@octokit/types": 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + "@octokit/endpoint@11.0.3": + dependencies: + "@octokit/types": 16.0.0 + universal-user-agent: 7.0.3 + + "@octokit/graphql@9.0.3": + dependencies: + "@octokit/request": 10.0.9 + "@octokit/types": 16.0.0 + universal-user-agent: 7.0.3 + + "@octokit/oauth-authorization-url@8.0.0": {} + + "@octokit/oauth-methods@6.0.2": + dependencies: + "@octokit/oauth-authorization-url": 8.0.0 + "@octokit/request": 10.0.9 + "@octokit/request-error": 7.1.0 + "@octokit/types": 16.0.0 + + "@octokit/openapi-types@27.0.0": {} + + "@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)": + dependencies: + "@octokit/core": 7.0.6 + "@octokit/types": 16.0.0 + + "@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)": + dependencies: + "@octokit/core": 7.0.6 + + "@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)": + dependencies: + "@octokit/core": 7.0.6 + "@octokit/types": 16.0.0 + + "@octokit/request-error@7.1.0": + dependencies: + "@octokit/types": 16.0.0 + + "@octokit/request@10.0.9": + dependencies: + "@octokit/endpoint": 11.0.3 + "@octokit/request-error": 7.1.0 + "@octokit/types": 16.0.0 + content-type: 2.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + "@octokit/rest@22.0.1": + dependencies: + "@octokit/core": 7.0.6 + "@octokit/plugin-paginate-rest": 14.0.0(@octokit/core@7.0.6) + "@octokit/plugin-request-log": 6.0.0(@octokit/core@7.0.6) + "@octokit/plugin-rest-endpoint-methods": 17.0.0(@octokit/core@7.0.6) + + "@octokit/types@16.0.0": + dependencies: + "@octokit/openapi-types": 27.0.0 + "@open-draft/deferred-promise@2.2.0": {} "@open-draft/deferred-promise@3.0.0": {} @@ -13333,6 +13632,7 @@ snapshots: "@sentry/junior@file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.33)(@opentelemetry/api@1.9.1)(@sentry/node@10.50.0-alpha.0)": dependencies: "@ai-sdk/gateway": 3.0.110(zod@4.4.3) + "@chat-adapter/github": 4.28.1 "@chat-adapter/slack": 4.28.1 "@chat-adapter/state-memory": 4.28.1 "@chat-adapter/state-redis": 4.28.1(@opentelemetry/api@1.9.1) @@ -13371,6 +13671,7 @@ snapshots: "@sentry/junior@file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.33)(@opentelemetry/api@1.9.1)(@sentry/node@10.51.0)": dependencies: "@ai-sdk/gateway": 3.0.110(zod@4.4.3) + "@chat-adapter/github": 4.28.1 "@chat-adapter/slack": 4.28.1 "@chat-adapter/state-memory": 4.28.1 "@chat-adapter/state-redis": 4.28.1(@opentelemetry/api@1.9.1) @@ -14359,10 +14660,9 @@ snapshots: execa: 5.1.1 smol-toml: 1.5.2 - "@vercel/sandbox@2.0.0-beta.19": + "@vercel/sandbox@1.9.0": dependencies: "@vercel/oidc": 3.2.0 - "@workflow/serde": 4.1.0-beta.2 async-retry: 1.3.3 jsonlines: 0.1.1 ms: 2.1.3 @@ -14375,9 +14675,10 @@ snapshots: - bare-abort-controller - react-native-b4a - "@vercel/sandbox@1.9.0": + "@vercel/sandbox@2.0.0-beta.19": dependencies: "@vercel/oidc": 3.2.0 + "@workflow/serde": 4.1.0-beta.2 async-retry: 1.3.3 jsonlines: 0.1.1 ms: 2.1.3 @@ -14773,6 +15074,8 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + before-after-hook@4.0.0: {} + bignumber.js@9.3.1: {} bindings@1.5.0: @@ -14984,6 +15287,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + convert-hrtime@3.0.0: {} convert-source-map@2.0.0: {} @@ -15489,6 +15794,8 @@ snapshots: extend@3.0.2: {} + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -16142,6 +16449,8 @@ snapshots: json-schema@0.4.0: {} + json-with-bigint@3.5.8: {} + json5@2.2.3: {} jsonc-parser@2.3.1: {} @@ -18270,6 +18579,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.0: {} toidentifier@1.0.1: {} @@ -18494,6 +18805,10 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-github-app-jwt@2.2.2: {} + + universal-user-agent@7.0.3: {} + universalify@2.0.1: {} unpipe@1.0.0: {} diff --git a/specs/github-agent-delivery-spec.md b/specs/github-agent-delivery-spec.md new file mode 100644 index 00000000..63353e65 --- /dev/null +++ b/specs/github-agent-delivery-spec.md @@ -0,0 +1,145 @@ +# GitHub Agent Delivery Spec + +## Metadata + +- Created: 2026-05-16 +- Last Edited: 2026-05-16 + +## Changelog + +- 2026-05-16: Initial canonical contract for GitHub mention-based entry surfaces, one-turn delivery semantics, and V1 platform/tool boundaries. + +## Status + +Active + +## Purpose + +Define the canonical user-visible GitHub delivery contract for Junior V1: + +- which GitHub webhook comment surfaces can start a turn +- when a GitHub comment should and should not trigger work +- how final GitHub replies are delivered and formatted +- how GitHub turns differ from Slack turns for tools and prompt instructions + +## Scope + +- GitHub App webhook ingress through `/api/webhooks/github` +- Mention-only V1 behavior on issue comments, PR conversation comments, and PR review comments +- Final GitHub comment reply semantics for a single turn +- GitHub-flavored markdown output expectations +- GitHub turn tool-profile constraints and Slack isolation +- Retry, dedupe, and self-message handling needed for safe comment delivery + +## Non-Goals + +- Responding to untagged comments +- Event-driven autonomous behaviors (`issues.opened`, `pull_request.opened`, label changes, review requested) +- Streaming partial assistant text, typing/status indicators, or progressive comment edits +- GitHub DMs, slash commands, app-home style UX, file upload workflows, or Slack-style channel posting features +- Building a generic event-rule engine + +## Contracts + +### 1. Supported V1 Entry Surfaces + +Junior supports GitHub mentions only from these webhook-backed comment surfaces: + +1. `issue_comment` on issues. +2. `issue_comment` on pull requests (conversation tab comments). +3. `pull_request_review_comment` on review threads (files changed comments). + +Any other GitHub webhook event is out of contract for V1 unless it carries one of the above comment mentions and adapter routing normalizes it to the same mention path. + +### 2. Mention-Only Trigger Contract + +V1 execution is explicit mention only. + +1. A turn starts only when Junior is explicitly tagged in the GitHub comment body. +2. Untagged comments do not start work. +3. Subscribed-thread or passive follow-up behavior must not cause GitHub replies without a fresh explicit mention. +4. Self-authored comments from Junior must not trigger new turns. + +### 3. One-Turn Delivery Contract + +For each valid explicit mention, Junior executes one turn and posts one final visible GitHub comment reply. + +1. The visible user-facing artifact for V1 is a finalized GitHub comment. +2. V1 does not stream partial text or expose in-flight status surfaces. +3. Delivery success is defined by accepted final GitHub comment creation in the correct target thread context. +4. If generation fails, Junior posts a GitHub-safe fallback error comment. + +### 4. Markdown Contract + +GitHub turns use GitHub-flavored markdown (GFM) as the output contract. + +1. Output instructions must target GitHub comment readability. +2. Slack-specific markdown guidance and Slack action guidance must not be applied to GitHub turns. +3. Runtime/prompt metadata should declare GitHub output format explicitly rather than inferring from thread IDs. + +### 5. Tool Profile Contract + +GitHub turns run with a GitHub-safe tool profile. + +1. Core tools remain available (sandbox, web, MCP/provider, and runtime-safe helpers). +2. Slack-only side-effect tools are excluded from GitHub turns. +3. Tool selection must be explicit via a surface/tool-profile contract, not implicit Slack channel capability checks. + +### 6. Retry, Dedupe, and Idempotency Contract + +GitHub webhook handling and delivery must avoid duplicate work from retried deliveries. + +1. Webhook signature verification must gate processing. +2. Duplicate webhook deliveries for the same mention must not result in duplicated assistant turn delivery. +3. Comment delivery should be idempotent at the turn boundary when retries occur. +4. Internal retries may occur for transient provider/network failures, but must preserve single-visible-reply semantics for one mention. + +### 7. Future Event-Driven Extension Point + +V1 must preserve explicit seams for future autonomous event handling without implementing it now. + +Required explicit concepts: + +- platform/surface classification +- tool profile selection +- delivery target selection + +A future event-trigger runtime may reuse these seams, but event-rule matching and autonomous turn policy remain out of scope for this spec. + +## Failure Model + +1. Invalid GitHub webhook signature: reject request and do not run a turn. +2. Unsupported or untagged GitHub comment event: acknowledge without delivery side effects. +3. Final GitHub comment delivery failure: treat as turn failure and emit fallback handling where possible. +4. Slack-only tools appearing in GitHub runs: contract violation. +5. Slack behavior regression caused by GitHub path changes: contract violation. + +## Observability + +GitHub delivery paths must emit enough diagnostics to distinguish: + +- webhook authentication/validation failures +- ignored untagged comments vs handled mention comments +- generation failures vs final GitHub comment post failures +- duplicate delivery suppression outcomes + +Required attribute families should include platform, repository/thread identifiers, run/turn identifiers, and requester identifiers consistent with logging specs. + +## Verification + +Required coverage for this contract: + +1. Integration: signed `/api/webhooks/github` request reaches mention handling path. +2. Integration: explicit mention in issue comment posts one final GitHub reply. +3. Integration: explicit mention in PR conversation comment posts one final GitHub reply. +4. Integration: explicit mention in PR review comment thread posts reply in correct review thread context. +5. Integration: untagged comments do not trigger agent execution. +6. Integration: GitHub turns do not expose Slack-only tools. +7. Integration: Slack mention behavior remains unchanged. + +## Related Specs + +- `./chat-architecture-spec.md` +- `./agent-prompt-spec.md` +- `./slack-agent-delivery-spec.md` +- `./testing/index.md` diff --git a/specs/index.md b/specs/index.md index 9c3b8c4a..a2add17f 100644 --- a/specs/index.md +++ b/specs/index.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-03 -- Last Edited: 2026-05-13 +- Last Edited: 2026-05-16 ## Changelog @@ -17,6 +17,7 @@ - 2026-04-28: Added canonical agent prompt spec. - 2026-05-06: Added draft advisor tool spec. - 2026-05-13: Added ownership map for chat, agent session, and Slack delivery specs. +- 2026-05-16: Added canonical GitHub agent delivery spec. ## Status @@ -51,6 +52,7 @@ Define spec taxonomy, naming conventions, and canonical source-of-truth document - `specs/security-policy.md` - `specs/chat-architecture-spec.md` - `specs/slack-agent-delivery-spec.md` +- `specs/github-agent-delivery-spec.md` - `specs/slack-outbound-contract-spec.md` - `specs/skill-capabilities-spec.md` - `specs/oauth-flows-spec.md` diff --git a/specs/plugin-spec.md b/specs/plugin-spec.md index 1b5d2bb9..b3210aa5 100644 --- a/specs/plugin-spec.md +++ b/specs/plugin-spec.md @@ -24,6 +24,7 @@ - 2026-05-03: Added plugin-level `api-headers` injection backed by declared deployment env vars. - 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-16: Added per-platform plugin and skill availability configuration. ## Status @@ -63,6 +64,7 @@ Define a plugin model where provider integrations are self-contained directories 8. `loadSkill` activates the provider catalog and returns provider/count metadata once the MCP server is connected and `listTools` succeeds. If connection/listing needs MCP OAuth, `loadSkill` initiates the MCP auth pause and the resumed turn re-activates the catalog before the model continues. `searchMcpTools` returns focused descriptors, including input/output schema and annotations, for any available active-provider tool before `callMcpTool` executes it. 9. Runtime setup belongs to `plugin.yaml`: CLI packages, system packages, postinstall commands, MCP endpoints/tool allowlists, credential delivery, command env, OAuth, and provider config keys are manifest declarations, not skill instructions. 10. Skills consume the plugin-provided runtime surface. They must not instruct the agent to install packages, bootstrap CLIs, configure MCP servers, create credentials, or repair sandbox package installation as part of normal workflow. +11. Installed plugin packages are a deployment bundle allowlist. Runtime provider availability is scoped again per platform through app configuration. ## Plugin directory structure @@ -478,7 +480,36 @@ Keys must be registered plugin config keys (`provider.key` declared in a loaded Resolution precedence (highest wins): 1. Channel-scoped overrides (persisted via `jr-rpc config set`) -2. Install-wide defaults (`configDefaults` in `createApp()`) +2. Platform defaults (`platforms..configDefaults`) +3. Install-wide defaults (`configDefaults` in `createApp()`) + +### Platform availability + +`pluginPackages` controls which plugin package content is bundled into the deployment. It does not by itself grant every chat surface access to every provider. Deployers can scope provider and skill availability per platform: + +```typescript +juniorNitro({ + pluginPackages: ["@sentry/junior-github", "@sentry/junior-sentry"], + platforms: { + slack: { + plugins: ["github", "sentry"], + skills: ["github-issues", "sentry-issues"], + }, + github: { + plugins: ["github"], + skills: ["github-issues", "github-pr-review"], + }, + }, +}); +``` + +Rules: + +- `platforms` keys enable platforms. When `platforms` is present, `enabledPlatforms` must match those keys if it is also provided. +- `plugins` uses plugin manifest names, not npm package names. The list is explicit and may be empty. +- `skills` is optional. When omitted, all standalone skills plus skills owned by enabled plugins are available. When present, it is an exact skill-name allowlist. +- A skill owned by a disabled plugin is invalid configuration. +- Platform plugin availability scopes prompt provider catalogs, MCP providers, plugin auth handling, sandbox command env, sandbox egress network policy, and sandbox egress credential activation. ## Skill integration diff --git a/specs/skill-capabilities-spec.md b/specs/skill-capabilities-spec.md index a6279496..d0b5d639 100644 --- a/specs/skill-capabilities-spec.md +++ b/specs/skill-capabilities-spec.md @@ -15,6 +15,7 @@ - 2026-05-08: Added plugin-owned `command-env` as a non-secret CLI compatibility surface. - 2026-05-12: Added Vercel Sandbox egress proxy activation for request-time credential issuance. - 2026-05-13: Removed the old per-turn credential runtime in favor of egress proxy-only credential activation. +- 2026-05-16: Scoped registered provider availability by platform configuration. ## Status @@ -34,7 +35,7 @@ Define how Junior maps registered plugin provider domains to host-managed creden 1. Plugins own provider permissions in `plugin.yaml`. 2. Skills do not declare capabilities or config keys. -3. Registered providers are always available to sandbox commands. +3. Registered providers are available to sandbox commands only when enabled for the current platform turn. 4. The agent runs the real provider command. 5. The runtime resolves the provider from the outgoing request host, issues a command-scoped provider lease, and injects credentials for that request only. 6. If auth is missing or stale, the proxy returns a command-readable auth-required response and the command failure path starts a private OAuth flow, then resumes the paused turn after authorization. @@ -76,7 +77,7 @@ Rules: ### Lease issuance - Resolve provider from the Vercel Sandbox forwarded host for proxied sandbox egress. -- Require requester context before issuing provider credentials. +- Preserve requester context when issuing user-scoped provider credentials; host-scoped providers may issue without a requester when the platform explicitly enables that provider. - Return short-lived leases only. - Keep any host-side egress lease cache bounded by the sandbox egress session expiry and lease expiry. @@ -91,6 +92,7 @@ Rules: ### Sandbox egress proxy - New sandbox sessions use a Vercel Sandbox network policy that forwards declared credential provider domains to Junior's internal egress route. +- New sandbox sessions persist the platform-enabled provider names, and the egress route rejects forwarded requests for providers outside that session allowlist. - The internal egress route must verify the Vercel Sandbox OIDC token before proxying. - The egress route must reconstruct the upstream URL only from Vercel forwarded host/scheme/port headers and the request path. - The egress route must reject forwarded hosts that do not match a registered provider domain.