From 81c0da145aa25be9db3e0752270f05c9fec6dce0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:11:11 +0000 Subject: [PATCH 1/2] feat(dispatch): Start DM dispatches for linked provider identities Plugin webhook routes can call ctx.agent.dispatchForIdentity with a provider account id. Core binds the provider to the calling plugin and starts a private Slack DM dispatch only when that Identity is linked through OAuth to exactly one current Slack member of the bot workspace. Otherwise it returns not_linked before any model call. Refs #2207 Co-Authored-By: BYK --- packages/junior-plugin-api/README.md | 6 + packages/junior-plugin-api/src/dispatch.ts | 10 ++ packages/junior-plugin-api/src/operations.ts | 19 ++- packages/junior-plugin-api/src/schemas.ts | 14 ++ packages/junior/src/app.ts | 8 +- .../junior/src/chat/agent-dispatch/README.md | 37 +++- .../junior/src/chat/agent-dispatch/context.ts | 95 ++++++++++- packages/junior/src/chat/events/workspace.ts | 4 +- packages/junior/src/chat/identities/README.md | 4 + packages/junior/src/chat/identities/sql.ts | 41 +++++ .../junior/src/chat/plugins/agent-hooks.ts | 10 ++ packages/junior/src/chat/slack/users.ts | 30 ++++ .../integration/identity-dispatch.test.ts | 159 ++++++++++++++++++ .../tests/unit/plugins/agent-hooks.test.ts | 20 ++- 14 files changed, 444 insertions(+), 13 deletions(-) create mode 100644 packages/junior/tests/integration/identity-dispatch.test.ts diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 3943e23421..ecd5e3a37f 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -109,6 +109,12 @@ the server, because a hidden link does not protect a route. owns browser rendering. - `ctx.agent.dispatch` creates durable agent work with an explicit actor, destination, source, metadata, and idempotency identity. +- In a webhook route, `ctx.agent.dispatchForIdentity` starts private work in a + Slack DM for the person behind one Identity of the plugin's provider. Give + the provider account id that the OAuth account-linking flow stored. Core + returns `not_linked` and starts no work when it cannot find exactly one + current Slack member linked to that Identity. Handles, emails, and display + names never match. - Dispatches may include compact `replyAttribution` for destination-visible context about what produced the reply. Core owns platform rendering; opaque dispatch metadata remains internal. diff --git a/packages/junior-plugin-api/src/dispatch.ts b/packages/junior-plugin-api/src/dispatch.ts index 8c2f79d4e6..98cea5df5b 100644 --- a/packages/junior-plugin-api/src/dispatch.ts +++ b/packages/junior-plugin-api/src/dispatch.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { destinationVisibilitySchema, dispatchOptionsSchema, + identityDispatchOptionsSchema, replyAttributionSchema, taskOutcomeSchema, } from "./schemas"; @@ -10,6 +11,9 @@ export type DestinationVisibility = z.output< typeof destinationVisibilitySchema >; export type DispatchOptions = z.output; +export type IdentityDispatchOptions = z.output< + typeof identityDispatchOptionsSchema +>; /** Compact destination-visible context explaining what produced a reply. */ export type ReplyAttribution = z.output; export type TaskOutcome = z.output; @@ -19,6 +23,12 @@ export interface DispatchResult { status: "created" | "already_exists"; } +/** + * Result of an Identity dispatch. `not_linked` means core found no current + * Slack member linked to the Identity, so it started no work. + */ +export type IdentityDispatchResult = DispatchResult | { status: "not_linked" }; + export interface Dispatch { errorMessage?: string; id: string; diff --git a/packages/junior-plugin-api/src/operations.ts b/packages/junior-plugin-api/src/operations.ts index 33efe25b74..9e37f1f393 100644 --- a/packages/junior-plugin-api/src/operations.ts +++ b/packages/junior-plugin-api/src/operations.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import type { PluginContext, User } from "./context"; -import type { Dispatch, DispatchOptions, DispatchResult } from "./dispatch"; +import type { + Dispatch, + DispatchOptions, + DispatchResult, + IdentityDispatchOptions, + IdentityDispatchResult, +} from "./dispatch"; import { nonBlankStringSchema } from "./schemas"; import type { PluginReadState, PluginState } from "./state"; import type { EventPublisher } from "./events"; @@ -156,6 +162,17 @@ export type PluginRouteApp = { }; export interface RouteRegistrationHookContext extends PluginContext { + agent: { + /** + * Start private work in a Slack DM for the person behind one Identity of + * this plugin's provider. Core starts work only when the Identity is linked + * through the OAuth account-linking flow to exactly one current Slack + * member. Otherwise it returns `not_linked` and calls no model. + */ + dispatchForIdentity( + options: IdentityDispatchOptions, + ): Promise; + }; annotations: PluginConversationAnnotations; /** Provider-neutral write boundary for Junior's native code index. */ codeChanges: CodeChangePublisher; diff --git a/packages/junior-plugin-api/src/schemas.ts b/packages/junior-plugin-api/src/schemas.ts index 895ab1632e..9dc14a1955 100644 --- a/packages/junior-plugin-api/src/schemas.ts +++ b/packages/junior-plugin-api/src/schemas.ts @@ -352,6 +352,20 @@ export const replyAttributionSchema = z }) .strict(); +/** + * Plugin route request to start private work for the person behind one + * provider Identity. Core binds the provider to the calling plugin. + */ +export const identityDispatchOptionsSchema = z + .object({ + idempotencyKey: nonBlankStringSchema.pipe(z.string().max(512)), + input: nonBlankStringSchema.pipe(z.string().max(32_000)), + replyAttribution: replyAttributionSchema.optional(), + /** Provider account id stored by the OAuth account-linking flow. */ + subjectId: exactNonBlankStringSchema.pipe(z.string().max(255)), + }) + .strict(); + /** Plugin dispatch request accepted by Junior core. */ export const dispatchOptionsSchema = z .object({ diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 70823abd55..2da53087e3 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -107,6 +107,7 @@ import { createVercelAttachmentStorage } from "@/chat/attachments/vercel"; import { publicArtifactGET } from "@/handlers/artifacts"; import type { WaitUntilFn } from "@/handlers/types"; import { createEventAppPublisher } from "@/chat/events/app-publisher"; +import { createIdentityDispatcher } from "@/chat/agent-dispatch/context"; import { receiveLocalOAuthCredential } from "@/chat/local/credential-sync"; import { getStateAdapter } from "@/chat/state/adapter"; import { createAcpConversations } from "@/api/acp/conversations"; @@ -463,7 +464,12 @@ export async function createApp(options?: JuniorAppOptions): Promise { configuredPlugins?.registrations ?? [], ); } - pluginRoutes = getPluginRoutes({ events }); + pluginRoutes = getPluginRoutes({ + dispatchForIdentity: createIdentityDispatcher({ + conversationWorkQueue: () => conversationWorkQueue, + }), + events, + }); if (dashboard && !dashboard.disabled) { pluginApiRoutes = getPluginApiRoutes(); } diff --git a/packages/junior/src/chat/agent-dispatch/README.md b/packages/junior/src/chat/agent-dispatch/README.md index 1a52d04584..61a016c70b 100644 --- a/packages/junior/src/chat/agent-dispatch/README.md +++ b/packages/junior/src/chat/agent-dispatch/README.md @@ -21,6 +21,37 @@ credential subject, Source, Destination, and plugin metadata from the dispatch record. The Turn saves Actor for later Runs. The dispatch record remains the authority for the other facts until their owning contract changes. +## Identity Dispatch + +A plugin webhook route can start private work for one person with +`ctx.agent.dispatchForIdentity`. The plugin gives the provider account id of +that person. Core uses the plugin name as the provider. A plugin cannot name an +Identity of another provider. + +Core starts work only when all of these rules are true: + +1. Junior has a Slack bot, and Slack `auth.test` gives the bot team id. +2. The provider is not a core Identity namespace (`junior` or `slack`). +3. One Identity has this provider, an empty tenant, this exact account id, + kind `user`, and a linked user. +4. That user has exactly one Slack Identity in the bot team. +5. Slack `users.info` shows that Slack user in the bot team. The user is not + deleted and is not a bot. + +If one rule is false, core returns `not_linked`. It starts no work and calls no +model. Missing Slack setup also returns `not_linked`, because core cannot find +a current Slack member. Other lookup and storage errors propagate to the route. + +When all rules are true, core opens the bot DM with that Slack user. It then +creates a normal plugin dispatch to the DM with `private` visibility. The +dispatch has the `private-direct-conversation` credential subject for that +Slack user. Core binds and checks this subject with the same code as heartbeat +dispatches. The idempotency key has the same scope as other dispatches of that +plugin. + +Only identities that the OAuth account-linking flow linked count. Provider +handles, emails from content, and display names never start this work. + ## Conversation Title A dispatched run has no human message. The run input is a bot-authored user @@ -54,7 +85,8 @@ holding that lock. ## Boundaries -- `context.ts` exposes the authorized core and plugin dispatch entry points. +- `context.ts` exposes the authorized core and plugin dispatch entry points, + which include Identity dispatch for plugin routes. - `store.ts` owns dispatch records, projections, and mailbox-append recovery receipts. - `work.ts` adapts dispatches to conversation work and projects durable turn @@ -66,4 +98,5 @@ holding that lock. Representative behavior coverage lives in `tests/integration/agent-dispatch-work.test.ts`; local transition and authority -contracts live in `tests/component/agent-dispatch-worker.test.ts`. +contracts live in `tests/component/agent-dispatch-worker.test.ts`. Identity +dispatch coverage lives in `tests/integration/identity-dispatch.test.ts`. diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index be438ed8a8..b34d18d436 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -1,15 +1,24 @@ -import type { - HeartbeatHookContext, - PluginRegistration, +import { + identityDispatchOptionsSchema, + type HeartbeatHookContext, + type IdentityDispatchOptions, + type IdentityDispatchResult, + type PluginRegistration, } from "@sentry/junior-plugin-api"; import { bindEventAutomationCredentialSubject, bindScheduledAutomationCredentialSubject, bindSlackDirectCredentialSubject, } from "@/chat/credentials/subject"; -import { getDb } from "@/chat/db"; +import { getDb, getSqlExecutor } from "@/chat/db"; +import { createEventTeamIdResolver } from "@/chat/events/workspace"; +import { findLinkedSlackUserId } from "@/chat/identities/sql"; +import { logInfo } from "@/chat/logging"; import { createPluginLogger } from "@/chat/plugins/logging"; import { createPluginState } from "@/chat/plugins/state"; +import { parseSlackUserId } from "@/chat/slack/ids"; +import { openSlackDirectMessage } from "@/chat/slack/outbound"; +import { isActiveSlackWorkspaceMember } from "@/chat/slack/users"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { createOrGetDispatch, @@ -128,6 +137,84 @@ export function createHeartbeatContext(args: { }; } +// Core owns these Identity namespaces. They are not accounts from a plugin's +// OAuth account-linking flow, so they never start Identity dispatches. +const CORE_IDENTITY_PROVIDERS = new Set(["junior", "slack"]); + +/** + * Build the plugin route entry point that starts private DM work for the + * person behind one provider Identity. + * + * The provider is always the calling plugin. Work starts only when the + * Identity is linked to exactly one Slack user in the bot's workspace and that + * user is a current human member. Without a Slack bot, nothing is linked. + */ +export function createIdentityDispatcher(args: { + conversationWorkQueue: () => ConversationWorkQueue; +}): ( + plugin: string, + options: IdentityDispatchOptions, +) => Promise { + const resolveSlackTeamId = createEventTeamIdResolver(); + const notLinked = (plugin: string, reason: string) => { + logInfo("dispatch.identity.not_linked", { + "app.plugin.name": plugin, + "app.dispatch.identity.reason": reason, + }); + return { status: "not_linked" } as const; + }; + + return async (plugin, options) => { + const request = identityDispatchOptionsSchema.parse(options); + if (CORE_IDENTITY_PROVIDERS.has(plugin)) { + return notLinked(plugin, "core_provider"); + } + const teamId = await resolveSlackTeamId(); + if (!teamId) { + return notLinked(plugin, "no_slack_bot"); + } + const slackUserId = parseSlackUserId( + await findLinkedSlackUserId(getSqlExecutor(), { + provider: plugin, + providerSubjectId: request.subjectId, + slackTeamId: teamId, + }), + ); + if (!slackUserId) { + return notLinked(plugin, "no_single_link"); + } + if (!(await isActiveSlackWorkspaceMember(slackUserId, teamId))) { + return notLinked(plugin, "inactive_member"); + } + + const dispatchOptions: SlackDispatchOptions = { + idempotencyKey: request.idempotencyKey, + credentialSubject: { + type: "user", + userId: slackUserId, + allowedWhen: "private-direct-conversation", + }, + destination: { + platform: "slack", + teamId, + channelId: await openSlackDirectMessage(slackUserId), + }, + destinationVisibility: "private", + input: request.input, + ...(request.replyAttribution + ? { replyAttribution: request.replyAttribution } + : undefined), + }; + validateDispatchOptions(dispatchOptions); + return await dispatch({ + conversationWorkQueue: args.conversationWorkQueue(), + nowMs: Date.now(), + options: bindDispatchCredentialSubject(dispatchOptions, plugin), + plugin, + }); + }; +} + /** Create and enqueue one core-owned event automation dispatch. */ export async function dispatchEventAutomation(args: { conversationWorkQueue: ConversationWorkQueue; diff --git a/packages/junior/src/chat/events/workspace.ts b/packages/junior/src/chat/events/workspace.ts index f9a287ebb6..9b2cd056c0 100644 --- a/packages/junior/src/chat/events/workspace.ts +++ b/packages/junior/src/chat/events/workspace.ts @@ -2,8 +2,8 @@ import { getSlackBotToken } from "@/chat/config"; import { getSlackClient } from "@/chat/slack/client"; /** - * Resolve the single-bot Slack team id for event-automation indexing and - * Event people mentions. + * Resolve the single-bot Slack team id for event-automation indexing, Event + * people mentions, and Identity dispatches. * * Watch matching does not use this. Event automations still key by * destination team until that store is conversation-owned. Event delivery diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index b8c10296b1..327351c712 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -38,3 +38,7 @@ the account. The person must start the connection from Slack or the web UI. Provider handles and subject IDs always remain identity-scoped. Display names are presentation data and must never be used to link identities or grant authority. + +Identity dispatch lets a plugin route start private work for the person behind +a linked provider account. It matches only the exact account id from this +linking flow. See `chat/agent-dispatch/README.md`. diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 725022ad35..5fbe47196a 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db"; import { juniorIdentities, juniorUsers } from "@/db/schema"; import { @@ -264,6 +265,46 @@ export async function findUserByVerifiedEmails( return rows.length === 1 ? rows[0] : undefined; } +const linkedSlackIdentity = alias(juniorIdentities, "linked_slack_identity"); + +/** + * Find the one Slack user in a workspace who is linked to an exact provider + * Identity. + * + * Only the provider account id from an account-linking flow matches. Handles, + * emails, and display names never match. Return undefined when no Slack user + * or more than one Slack user matches. + */ +export async function findLinkedSlackUserId( + executor: JuniorSqlDatabase, + args: { provider: string; providerSubjectId: string; slackTeamId: string }, +): Promise { + const rows = await executor + .db() + .select({ slackUserId: linkedSlackIdentity.providerSubjectId }) + .from(juniorIdentities) + // The join needs a user on both sides, so unlinked identities drop out. + .innerJoin( + linkedSlackIdentity, + and( + eq(linkedSlackIdentity.userId, juniorIdentities.userId), + eq(linkedSlackIdentity.kind, "user"), + eq(linkedSlackIdentity.provider, "slack"), + eq(linkedSlackIdentity.providerTenantId, args.slackTeamId), + ), + ) + .where( + and( + eq(juniorIdentities.kind, "user"), + eq(juniorIdentities.provider, args.provider), + eq(juniorIdentities.providerTenantId, ""), + eq(juniorIdentities.providerSubjectId, args.providerSubjectId), + ), + ) + .limit(2); + return rows.length === 1 ? rows[0]?.slackUserId : undefined; +} + /** Remove one exact provider identity owned by a workspace Slack user. */ export async function deleteProviderIdentityForSlackUser( executor: JuniorSqlDatabase, diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 0cc92c8cd9..57309d53a9 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -8,6 +8,8 @@ import { eventInputSchema, } from "@sentry/junior-plugin-api"; import type { + IdentityDispatchOptions, + IdentityDispatchResult, InvocationContext, PluginMcp, PluginMcpContent, @@ -888,6 +890,10 @@ function requirePublishedEvent( /** Collect route handlers exposed by plugins for app-level mounting. */ export function getPluginRoutes(options: { + dispatchForIdentity( + plugin: string, + request: IdentityDispatchOptions, + ): Promise; events: { hasMatch?(event: Event): Promise; neededMatchKeys?(input: { @@ -910,6 +916,10 @@ export function getPluginRoutes(options: { } const pluginRoutes = hook({ ...basePluginContext(plugin), + agent: { + dispatchForIdentity: (dispatchOptions) => + options.dispatchForIdentity(pluginName, dispatchOptions), + }, annotations: { forConversation: (conversationId) => createPluginAnnotations({ diff --git a/packages/junior/src/chat/slack/users.ts b/packages/junior/src/chat/slack/users.ts index 00674a1a09..9039cb8379 100644 --- a/packages/junior/src/chat/slack/users.ts +++ b/packages/junior/src/chat/slack/users.ts @@ -120,6 +120,36 @@ export async function lookupSlackUserProfile( return normalizeUser(user); } +/** + * Check that one Slack user is a current human member of a workspace. + * + * A user that Slack cannot find is not a member. Other lookup errors + * propagate. + */ +export async function isActiveSlackWorkspaceMember( + userId: SlackUserId, + teamId: string, +): Promise { + let user: SlackUserProfile; + try { + user = await lookupSlackUserProfile(userId); + } catch (error: unknown) { + if ( + error instanceof SlackActionError && + error.apiError === "user_not_found" + ) { + return false; + } + throw error; + } + return ( + user.id === userId && + user.team_id === teamId && + !user.is_deleted && + !user.is_bot + ); +} + /** Look up a Slack user by email. Returns null when no user matches. */ export async function lookupSlackUserByEmail( email: string, diff --git a/packages/junior/tests/integration/identity-dispatch.test.ts b/packages/junior/tests/integration/identity-dispatch.test.ts new file mode 100644 index 0000000000..b595e7f39e --- /dev/null +++ b/packages/junior/tests/integration/identity-dispatch.test.ts @@ -0,0 +1,159 @@ +import { + defineJuniorPlugin, + type RouteRegistrationHookContext, +} from "@sentry/junior-plugin-api"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createApp, defineJuniorPlugins } from "@/app"; +import { getDispatchRecord } from "@/chat/agent-dispatch/store"; +import { getSqlExecutor } from "@/chat/db"; +import { upsertIdentity, upsertLinkedIdentity } from "@/chat/identities/sql"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { usersInfoOk } from "../fixtures/slack/factories/api"; +import { + createConversationWorkQueueTestAdapter, + type ConversationWorkQueueTestAdapter, +} from "../fixtures/conversation-work"; +import { + getCapturedSlackApiCalls, + queueSlackApiResponse, +} from "../msw/handlers/slack-api"; + +/** Seed one Slack member and, optionally, a provider account linked by OAuth. */ +async function seedSlackUser(args: { + email: string; + linked?: { handle?: string; provider: string; subjectId: string }; + slackUserId: string; +}): Promise { + const slack = await upsertIdentity(getSqlExecutor(), { + kind: "user", + provider: "slack", + providerTenantId: "T123", + providerSubjectId: args.slackUserId, + email: args.email, + emailVerified: true, + }); + if (!args.linked) return; + await upsertLinkedIdentity(getSqlExecutor(), slack.userId!, { + kind: "user", + provider: args.linked.provider, + providerSubjectId: args.linked.subjectId, + ...(args.linked.handle ? { handle: args.linked.handle } : undefined), + }); +} + +describe("plugin route Identity dispatch", () => { + let agent: RouteRegistrationHookContext["agent"]; + let queue: ConversationWorkQueueTestAdapter; + + beforeEach(async () => { + await disconnectStateAdapter(); + queue = createConversationWorkQueueTestAdapter(); + await createApp({ + conversationWorkQueue: () => queue, + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { name: "demo", displayName: "Demo", description: "Demo" }, + hooks: { + routes(ctx) { + agent = ctx.agent; + return []; + }, + }, + }), + ]), + }); + }); + + it("starts one private DM dispatch for a linked current Slack member", async () => { + await seedSlackUser({ + email: "requester@example.com", + linked: { provider: "demo", subjectId: "1001" }, + slackUserId: "U123", + }); + const request = { + idempotencyKey: "delivery-1", + input: "Look at demo issue 7.", + subjectId: "1001", + }; + + const result = await agent.dispatchForIdentity(request); + + expect(result).toEqual({ id: expect.any(String), status: "created" }); + if (result.status !== "created") throw new Error("Expected a dispatch"); + expect( + getCapturedSlackApiCalls("conversations.open").map( + (call) => call.params.users, + ), + ).toEqual(["U123"]); + expect(queue.sentRecords()).toHaveLength(1); + await expect(getDispatchRecord(result.id)).resolves.toMatchObject({ + credentialSubject: { + type: "user", + userId: "U123", + allowedWhen: "private-direct-conversation", + binding: { + type: "slack-direct-conversation", + teamId: "T123", + channelId: "D0TEST", + signature: expect.any(String), + }, + }, + destination: { platform: "slack", teamId: "T123", channelId: "D0TEST" }, + destinationVisibility: "private", + input: "Look at demo issue 7.", + plugin: "demo", + source: { kind: "plugin_dispatch" }, + }); + + // A redelivered webhook reuses its idempotency key. + await expect(agent.dispatchForIdentity(request)).resolves.toEqual({ + id: result.id, + status: "already_exists", + }); + }); + + it("drops identities without exactly one current linked Slack member", async () => { + await seedSlackUser({ + email: "requester@example.com", + linked: { handle: "octocat", provider: "demo", subjectId: "1001" }, + slackUserId: "U123", + }); + // Another provider's account id does not name a demo Identity. + await seedSlackUser({ + email: "other@example.com", + linked: { provider: "github", subjectId: "2001" }, + slackUserId: "U222", + }); + await seedSlackUser({ + email: "former@example.com", + linked: { provider: "demo", subjectId: "1003" }, + slackUserId: "U456", + }); + // One person with two Slack accounts in the workspace is ambiguous. + await seedSlackUser({ email: "twice@example.com", slackUserId: "U777" }); + await seedSlackUser({ + email: "twice@example.com", + linked: { provider: "demo", subjectId: "1004" }, + slackUserId: "U778", + }); + queueSlackApiResponse("users.info", { + body: usersInfoOk({ userId: "U456", deleted: true }), + }); + + for (const subjectId of ["2001", "octocat", "1003", "1004"]) { + await expect( + agent.dispatchForIdentity({ + idempotencyKey: `delivery-${subjectId}`, + input: "Look at demo issue 7.", + subjectId, + }), + ).resolves.toEqual({ status: "not_linked" }); + } + + expect( + getCapturedSlackApiCalls("users.info").map((call) => call.params.user), + ).toEqual(["U456"]); + expect(getCapturedSlackApiCalls("conversations.open")).toEqual([]); + expect(queue.sentRecords()).toEqual([]); + }); +}); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 2996e79bc6..d91e7a2df3 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -93,6 +93,8 @@ const TEST_EGRESS = { }, }; +const dispatchForIdentity = async () => ({ status: "not_linked" as const }); + const SLACK_DESTINATION = { platform: "slack", teamId: "T123", @@ -1116,7 +1118,10 @@ describe("agent plugin hooks", () => { ]); try { const publish = vi.fn(async (_event: Event) => {}); - const routes = getPluginRoutes({ events: { publish } }); + const routes = getPluginRoutes({ + dispatchForIdentity, + events: { publish }, + }); expect(routes).toHaveLength(1); expect(routes[0]?.pluginName).toBe("agent-demo"); @@ -1172,7 +1177,10 @@ describe("agent plugin hooks", () => { ]); try { const publish = vi.fn(async () => {}); - const [route] = getPluginRoutes({ events: { publish } }); + const [route] = getPluginRoutes({ + dispatchForIdentity, + events: { publish }, + }); await expect( route!.handler(new Request("http://localhost/demo")), @@ -1236,7 +1244,10 @@ describe("agent plugin hooks", () => { ]); try { const publish = vi.fn(async () => {}); - const [route] = getPluginRoutes({ events: { publish } }); + const [route] = getPluginRoutes({ + dispatchForIdentity, + events: { publish }, + }); await expect( route!.handler(new Request("http://localhost/demo")), @@ -1271,6 +1282,7 @@ describe("agent plugin hooks", () => { try { expect(() => getPluginRoutes({ + dispatchForIdentity, events: { publish: async () => {} }, }), ).toThrow( @@ -1305,6 +1317,7 @@ describe("agent plugin hooks", () => { try { expect(() => getPluginRoutes({ + dispatchForIdentity, events: { publish: async () => {} }, }), ).toThrow( @@ -1344,6 +1357,7 @@ describe("agent plugin hooks", () => { try { expect(() => getPluginRoutes({ + dispatchForIdentity, events: { publish: async () => {} }, }), ).toThrow( From 94400c94dce8112f7d2bca736ff409e696ebf813 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:00:03 +0000 Subject: [PATCH 2/2] fix(dispatch): Block native identity providers from Identity dispatch Use isNativeIdentityProvider so a plugin named after a native provider, such as google, cannot start work for core-linked sign-in identities. Refs #2207 --- .../junior/src/chat/agent-dispatch/README.md | 3 +- .../junior/src/chat/agent-dispatch/context.ts | 12 +++++--- .../integration/identity-dispatch.test.ts | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/junior/src/chat/agent-dispatch/README.md b/packages/junior/src/chat/agent-dispatch/README.md index 1f92681d43..9d59d7bc24 100644 --- a/packages/junior/src/chat/agent-dispatch/README.md +++ b/packages/junior/src/chat/agent-dispatch/README.md @@ -31,7 +31,8 @@ Identity of another provider. Core starts work only when all of these rules are true: 1. Junior has a Slack bot, and Slack `auth.test` gives the bot team id. -2. The provider is not a core Identity namespace (`junior` or `slack`). +2. The provider is not a core Identity namespace: `junior` or a native + provider such as `slack` or `google`. 3. One Identity has this provider, an empty tenant, this exact account id, kind `user`, and a linked user. 4. That user has exactly one Slack Identity in the bot team. diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index d064d41359..b079770f40 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -12,6 +12,7 @@ import { } from "@/chat/credentials/subject"; import { getDb, getSqlExecutor } from "@/chat/db"; import { createEventTeamIdResolver } from "@/chat/events/workspace"; +import { isNativeIdentityProvider } from "@/chat/identities/identity"; import { findLinkedSlackUserId } from "@/chat/identities/sql"; import { isSlackProfileDomainAllowed } from "@/chat/ingress/slack-allowed-domain"; import { logInfo } from "@/chat/logging"; @@ -139,9 +140,12 @@ export function createHeartbeatContext(args: { }; } -// Core owns these Identity namespaces. They are not accounts from a plugin's -// OAuth account-linking flow, so they never start Identity dispatches. -const CORE_IDENTITY_PROVIDERS = new Set(["junior", "slack"]); +// Core owns native identities and the email-keyed `junior` web identities. +// They are not accounts from a plugin's OAuth account-linking flow, so they +// never start Identity dispatches. +function isCoreIdentityProvider(provider: string): boolean { + return provider === "junior" || isNativeIdentityProvider(provider); +} /** * Build the plugin route entry point that starts private DM work for the @@ -170,7 +174,7 @@ export function createIdentityDispatcher(args: { return async (plugin, options) => { const request = identityDispatchOptionsSchema.parse(options); - if (CORE_IDENTITY_PROVIDERS.has(plugin)) { + if (isCoreIdentityProvider(plugin)) { return notLinked(plugin, "core_provider"); } const teamId = await resolveSlackTeamId(); diff --git a/packages/junior/tests/integration/identity-dispatch.test.ts b/packages/junior/tests/integration/identity-dispatch.test.ts index 3712f19460..5e3c3640b7 100644 --- a/packages/junior/tests/integration/identity-dispatch.test.ts +++ b/packages/junior/tests/integration/identity-dispatch.test.ts @@ -43,6 +43,7 @@ async function seedSlackUser(args: { describe("plugin route Identity dispatch", () => { let agent: RouteRegistrationHookContext["agent"]; + let googleAgent: RouteRegistrationHookContext["agent"]; let queue: ConversationWorkQueueTestAdapter; async function startApp(allowedDomains?: string[]): Promise { @@ -61,6 +62,20 @@ describe("plugin route Identity dispatch", () => { }, }, }), + // A plugin that shares a native provider name. + defineJuniorPlugin({ + manifest: { + name: "google", + displayName: "Google", + description: "Google", + }, + hooks: { + routes(ctx) { + googleAgent = ctx.agent; + return []; + }, + }, + }), ]), }); } @@ -141,10 +156,23 @@ describe("plugin route Identity dispatch", () => { linked: { provider: "demo", subjectId: "1004" }, slackUserId: "U778", }); + // Core links native Google sign-in identities, not plugin OAuth. + await seedSlackUser({ + email: "signed-in@example.com", + linked: { provider: "google", subjectId: "google-sub-1" }, + slackUserId: "U333", + }); queueSlackApiResponse("users.info", { body: usersInfoOk({ userId: "U456", deleted: true }), }); + await expect( + googleAgent.dispatchForIdentity({ + idempotencyKey: "delivery-google", + input: "Look at demo issue 7.", + subjectId: "google-sub-1", + }), + ).resolves.toEqual({ status: "not_linked" }); for (const subjectId of ["2001", "octocat", "1003", "1004"]) { await expect( agent.dispatchForIdentity({