Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/junior-plugin-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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.
Expand Down
10 changes: 10 additions & 0 deletions packages/junior-plugin-api/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import {
destinationVisibilitySchema,
dispatchOptionsSchema,
identityDispatchOptionsSchema,
replyAttributionSchema,
taskOutcomeSchema,
} from "./schemas";
Expand All @@ -10,6 +11,9 @@ export type DestinationVisibility = z.output<
typeof destinationVisibilitySchema
>;
export type DispatchOptions = z.output<typeof dispatchOptionsSchema>;
export type IdentityDispatchOptions = z.output<
typeof identityDispatchOptionsSchema
>;
/** Compact destination-visible context explaining what produced a reply. */
export type ReplyAttribution = z.output<typeof replyAttributionSchema>;
export type TaskOutcome = z.output<typeof taskOutcomeSchema>;
Expand All @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion packages/junior-plugin-api/src/operations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { z } from "zod";
import type { Identity, 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";
Expand Down Expand Up @@ -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<IdentityDispatchResult>;
};
annotations: PluginConversationAnnotations;
/** Provider-neutral write boundary for Junior's native code index. */
codeChanges: CodeChangePublisher;
Expand Down
14 changes: 14 additions & 0 deletions packages/junior-plugin-api/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,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({
Expand Down
9 changes: 8 additions & 1 deletion packages/junior/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import { getAttachmentStorage } from "@/chat/attachments/storage";
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";
Expand Down Expand Up @@ -504,7 +505,13 @@ export async function createApp(options?: JuniorAppOptions): Promise<Hono> {
configuredPlugins?.registrations ?? [],
);
}
pluginRoutes = getPluginRoutes({ events });
pluginRoutes = getPluginRoutes({
dispatchForIdentity: createIdentityDispatcher({
allowedDomains,
conversationWorkQueue: () => conversationWorkQueue,
}),
events,
});
if (dashboard && !dashboard.disabled) {
pluginApiRoutes = getPluginApiRoutes();
}
Expand Down
40 changes: 38 additions & 2 deletions packages/junior/src/chat/agent-dispatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,40 @@ 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 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.
5. Slack `users.info` shows that Slack user in the bot team. The user is not
deleted and is not a bot.
6. The email of that Slack profile is on the allowed domains, the same rule as
Slack ingress. An empty list allows every domain.

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
Expand Down Expand Up @@ -54,7 +88,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
Expand All @@ -66,4 +101,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`.
106 changes: 102 additions & 4 deletions packages/junior/src/chat/agent-dispatch/context.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import { createPluginCache } from "@/chat/plugins/cache";
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 { isNativeIdentityProvider } from "@/chat/identities/identity";
import { findLinkedSlackUserId } from "@/chat/identities/sql";
import { isSlackProfileDomainAllowed } from "@/chat/ingress/slack-allowed-domain";
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 { lookupActiveSlackWorkspaceMember } from "@/chat/slack/users";
import type { ConversationWorkQueue } from "@/chat/task-execution/queue";
import {
buildDispatchId,
Expand Down Expand Up @@ -131,6 +142,93 @@ export function createHeartbeatContext(args: {
};
}

// 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
* 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, that
* user is a current human member, and the user's Slack email is on the allowed
* domains, as for Slack ingress. Without a Slack bot, nothing is linked.
*/
export function createIdentityDispatcher(args: {
allowedDomains: readonly string[];
conversationWorkQueue: () => ConversationWorkQueue;
}): (
plugin: string,
options: IdentityDispatchOptions,
) => Promise<IdentityDispatchResult> {
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 (isCoreIdentityProvider(plugin)) {
return notLinked(plugin, "core_provider");
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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");
}
const member = await lookupActiveSlackWorkspaceMember(slackUserId, teamId);
if (!member) {
return notLinked(plugin, "inactive_member");
}
if (!isSlackProfileDomainAllowed(member, args.allowedDomains)) {
return notLinked(plugin, "domain_not_allowed");
}

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: {
automationId: string;
Expand Down
4 changes: 2 additions & 2 deletions packages/junior/src/chat/events/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/junior/src/chat/identities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,19 @@ 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`.

## Allowed Domains

`chat/sign-in/allowlist.ts` owns one list of allowed domains for Slack and the
dashboard. An empty list allows every domain.

- Slack ingress starts a Turn from a mention or DM only when the email of the
author's Slack profile is on an allowed domain
(`chat/ingress/slack-allowed-domain.ts`).
(`chat/ingress/slack-allowed-domain.ts`). Identity dispatch applies the
same rule to the linked Slack user.
- `chat/sign-in/auth.ts` owns the one Better Auth instance of the dashboard. It
accepts a Google sign-in only when the verified `hd` claim is an allowed
domain. After each Google callback, a Better Auth hook saves the Google
Expand Down
41 changes: 41 additions & 0 deletions packages/junior/src/chat/identities/sql.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -265,6 +266,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<string | undefined> {
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,
Expand Down
Loading
Loading