mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Lifecycle plugins prepare agent runtimes outside the control plane. > - They need committed connection assignment changes without polling. > - A reader and its revision hooks must cover the same fields. > - This pull request adds a bounded standing-assignment snapshot and its matching hooks. > - Plugins can react to assignment changes while execution keeps its existing access checks. ## Linked Issues or Issue Description **What happened?** The agent runtime reader does not expose standing connection assignments. Changes to connection installs, active profiles, and connector feature switches do not request synchronization. Adding company-wide invalidation also exposes lock inversions in assignment writers and AI adoption. **Expected behavior** The owning lifecycle plugin can read a bounded assignment snapshot. Every committed change to its stored fields advances the runtime revision. Rollback removes both the change and its event. Assignment writers finish without a lock-order cycle. **Steps to reproduce** 1. Install a connection or bind an active profile to an agent. 2. Read its runtime inputs through the owning lifecycle plugin. 3. Rename or disable the connection, revoke the assignment, or switch connector features. 4. Expect a committed runtime event and an updated snapshot without periodic polling. **Additional context** Extracted from #16503. Catalog publication #16602 and the separate test maintenance PRs #16601 and #16610 are merged. This PR now targets master and is the active review. Tool-definition hooks #16604 and access/credential hooks #16605 remain drafts without review requests. ## What Changed - Add SDK types and an owning-plugin runtime snapshot for installed connections, active profile IDs, and effective connector feature switches. - Reject more than 512 connections or 128 active profiles instead of returning partial data. - Add migration 0354 for the exact stored fields used by the snapshot, with transaction-coalesced invalidation. Agent-targeted bindings and installs notify only their old/new target agents; project/routine/issue/gateway bindings are outside the snapshot and produce no notification. - Use ordered agent locks that are compatible with foreign-key references and exclude runtime readers. Prelock complete old/new target sets before replacing assignments or restoring managed profiles. - Record one company baseline at delivery startup for process-managed feature overlays. - Lock assignment inputs before install saves, task completion, and email assignment. Setup finalization reads the locked connection state and prelocks company agents before activating, re-enabling, or reviving a profile. - Make profile binding and reassignment writes transactional and lock parent profiles first. Keep target validation and profile/detail reads on the reserved transaction connection, including with a one-connection pool. - Lock company agents in order before validated AI adoption. Keep adoption and shared configuration writes at `FOR NO KEY UPDATE`, avoiding an upgrade that can deadlock foreign-key inserts during gateway creation. Configuration fields cannot change agent identity; lifecycle transitions and deletion retain their existing locks. - Document supported coverage and the inputs reserved for later PRs. ## Verification - Thirteen affected suites passed: 757 tests covering runtime hooks/assignments/skills, concurrent skill publication, tool access/catalog, task connection intents, lifecycle delivery, email, AI accounts, migration histories, snapshot drift, and the agent lifecycle module. This includes the merged catalog PR's OAuth and overlapping-refresh regressions. - The assignment suite passes all 24 cases, including bounded snapshots, company/agent isolation, rollback, health-field no-ops, process-start feature overlays, and real lock contention. - Four one-connection PostgreSQL regressions reproduce the earlier helper-read hangs before the correction; bind, unbind, delete, and reassign complete with it. The earlier adoption/gateway probe exposed both lock upgrades: changing only validation still deadlocks in shared configuration. Current scoped coverage verifies adoption with agent-targeted binding writes; gateway-only bindings no longer invalidate this snapshot. - The original ordered-adoption regression reproduces PostgreSQL `40P01` without ordered company-agent locking. Scoped-recipient tests reproduce unrelated-peer journaling before the trigger correction and verify repeated bind/unbind operations leave peers untouched, ignored binding types produce no events, both reassignment recipients are reached, and coalescing remains intact. - Six replacement contention cases cover installs, existing setup, activation, re-enabling, profile revival, and profile reassignment. Negative controls reproduce `40P01` without the corresponding target/company prelocks; all cases pass with the guards. Activation decisions use the connection row reread under lock. - An initial contention fixture failed because its settings connection lacked an MCP URL. Supplying the fixture URL and controlled lookup corrected that test setup error. - Server typecheck, database migration numbering/safety, five-snapshot retention, feature-map structural validation, and `git diff --check` passed. Migration metadata preserves historical journal entries and adds no schema change. The complete diff was reviewed for secrets and personal data before push. - Earlier hosted PR CI runs 38983440861 and 38985530069 passed; the superseded promotion run 38983440470 was cancelled. Superagent acknowledged the recipient fix and accepted the documented company-wide cost at `359b5b2f30a8a0248177367e458663d5379810d1`. Its two standard Greptile summaries were conditional on a later setup correction and were not counted as independent acceptance; the setup correction is now included here. - At `7da246aab46274db1a120a0546abbb404c5427c7`, hosted PR run 38986958988 passed: all 55 checks succeeded, with two optional Storybook checks skipped. Superagent passed, all review threads are resolved, and standard Greptile independently returned 5/5 with no blocking findings (review completed 2026-10-17 00:40 UTC). Apex independently returned 5/5 with no actionable findings (completed 2026-10-17 00:50 UTC). CI was rechecked after Apex and remains green; the branch is mergeable against master. Full local build and repository-wide tests were not run. ## Risks Migration 0354 adds database functions and triggers without changing table schema. Apply it through the normal migration flow. Agent-targeted bindings and installs only notify their affected agents. Explicit company-wide assignments, connection metadata, profile activation/removal, and connector feature switches retain synchronous, transaction-atomic company fanout. Each such transaction takes ordered row locks and inserts up to one connector runtime event per eligible company agent: O(number of eligible agents) work and journal storage, with no company-size cap or cross-transaction mutation quota. This remains an intentional scope tradeoff for this PR; deferred fanout is not implemented. Many of these conservative changes can request preparation even if an individual snapshot stays unchanged. Snapshot limits fail closed. This PR provides desired assignments, not credentials or task authorization. Later PRs must add mutation coverage before consumers can rely on immediate synchronization of tool definitions or credentials. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and testing. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1234 lines
81 KiB
TypeScript
1234 lines
81 KiB
TypeScript
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||
import { emailChannelService } from "./email-channels.js";
|
||
import { emailConnectionService } from "./email-connections.js";
|
||
import { grantConnectionAgentTools } from "./connection-agent-access.js";
|
||
import { agentService } from "./agents.js";
|
||
import { createHash } from "node:crypto";
|
||
import { logActivity } from "./activity-log.js";
|
||
import { aiConnectionService } from "./ai-connections.js";
|
||
import { aiConnectionBindingSchema, aiConnectionRouterBindingSchema } from "@paperclipai/shared";
|
||
import { aiBindingForAuthRecovery, isAiAuthenticationFailure, isAiAuthenticationRepairable } from "./ai-auth-failure.js";
|
||
import { and, eq, desc, isNull, sql } from "drizzle-orm";
|
||
import type { Db } from "@paperclipai/db";
|
||
import {
|
||
agents,
|
||
aiConnectionTaskPins,
|
||
companies,
|
||
toolConnections,
|
||
toolCatalogEntries,
|
||
toolProfiles,
|
||
toolProfileEntries,
|
||
toolProfileBindings,
|
||
toolPolicies,
|
||
companyMemberships,
|
||
heartbeatRuns,
|
||
issueThreadInteractions,
|
||
issueComments,
|
||
issues,
|
||
} from "@paperclipai/db";
|
||
import {
|
||
APP_STORE_DEFINITIONS,
|
||
AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES,
|
||
findAggregatorService, searchAggregatorServices, prepareConnectionSearch, scoreConnectionSearch, explicitAggregatorQuery, normalizeConnectionQuery, parseAggregatorRoute, aggregatorProviderQuestion, aggregatorProviderQuestionSet,
|
||
aggregatorContinuationInstruction, isRemoteMcpConnectorId, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema,
|
||
CONNECTABLE_APP_DEFINITIONS,
|
||
connectionIntentPayloadSchema,
|
||
getAvailableConnectionMethods,
|
||
isToolConnectionAttentionHealth,
|
||
type ConnectionSearchResultItem,
|
||
type AggregatorServiceDefinition,
|
||
getAppStoreDefinition,
|
||
type ConnectionIntentInteraction,
|
||
type ConnectionIntentSetupOptions,
|
||
type ConnectionRequestResult,
|
||
type ConnectionsSearchResult,
|
||
type ToolApplication,
|
||
type ToolConnection,
|
||
type AiConnectionAttribution,
|
||
type AiConnectionBinding,
|
||
} from "@paperclipai/shared";
|
||
import { instanceSettingsService } from "./instance-settings.js";
|
||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
|
||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||
import { lockToolConnectionRuntimeInputs, toolAccessService } from "./tool-access.js";
|
||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||
import { captureRunIdentity } from "./run-identity.js";
|
||
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
|
||
|
||
type ConnectionRunClaims = Pick<RuntimeToolsTokenClaims, "sub" | "company_id" | "run_id" | "responsible_user_id">;
|
||
|
||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||
|
||
function record(value: unknown): Record<string, unknown> | null {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? value as Record<string, unknown>
|
||
: null;
|
||
}
|
||
|
||
function text(value: unknown) {
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function sourceSlugForApplication(application: ToolApplication | undefined) {
|
||
return text(application?.metadata?.sourceTemplateKey) ?? text(application?.metadata?.galleryKey);
|
||
}
|
||
|
||
function sourceSlugForConnection(
|
||
connection: ToolConnection,
|
||
applications: ReadonlyMap<string, ToolApplication>,
|
||
) {
|
||
const source = text(connection.config?.sourceTemplateKey)
|
||
?? text(connection.transportConfig?.sourceTemplateKey)
|
||
?? sourceSlugForApplication(applications.get(connection.applicationId));
|
||
return source && getAppStoreDefinition(source) ? source : `connection:${connection.id}`;
|
||
}
|
||
|
||
|
||
function availableToolConnectionMethods(
|
||
app: (typeof CONNECTABLE_APP_DEFINITIONS)[number],
|
||
) {
|
||
return getAvailableConnectionMethods(app).filter(
|
||
(method) => (method.purpose ?? "tool") === "tool" && method.transport !== "runtime_auth",
|
||
);
|
||
}
|
||
|
||
export function connectionIntentService(db: Db) {
|
||
const interactions = issueThreadInteractionService(db);
|
||
const access = toolAccessService(db);
|
||
|
||
async function assertCurrentUserWriteAccess(
|
||
companyId: string,
|
||
userId: string,
|
||
bypassCurrentMembershipCheck = false,
|
||
) {
|
||
if (bypassCurrentMembershipCheck) return;
|
||
const membership = await db
|
||
.select({
|
||
status: companyMemberships.status,
|
||
membershipRole: companyMemberships.membershipRole,
|
||
})
|
||
.from(companyMemberships)
|
||
.where(and(
|
||
eq(companyMemberships.companyId, companyId),
|
||
eq(companyMemberships.principalType, "user"),
|
||
eq(companyMemberships.principalId, userId),
|
||
))
|
||
.then((rows) => rows[0] ?? null);
|
||
if (
|
||
!membership
|
||
|| membership.status !== "active"
|
||
|| !membership.membershipRole
|
||
|| membership.membershipRole === "viewer"
|
||
) {
|
||
throw forbidden("Addressed user is no longer authorized for company write access");
|
||
}
|
||
}
|
||
|
||
async function lockCurrentUserWriteAccess(
|
||
tx: DbTransaction,
|
||
companyId: string,
|
||
userId: string,
|
||
bypassCurrentMembershipCheck = false,
|
||
) {
|
||
if (bypassCurrentMembershipCheck) return;
|
||
const membership = await tx
|
||
.select({
|
||
status: companyMemberships.status,
|
||
membershipRole: companyMemberships.membershipRole,
|
||
})
|
||
.from(companyMemberships)
|
||
.where(and(
|
||
eq(companyMemberships.companyId, companyId),
|
||
eq(companyMemberships.principalType, "user"),
|
||
eq(companyMemberships.principalId, userId),
|
||
))
|
||
.for("update")
|
||
.then((rows) => rows[0] ?? null);
|
||
if (
|
||
!membership
|
||
|| membership.status !== "active"
|
||
|| !membership.membershipRole
|
||
|| membership.membershipRole === "viewer"
|
||
) {
|
||
throw forbidden("Addressed user is no longer authorized for company write access");
|
||
}
|
||
}
|
||
|
||
async function loadRunContext(claims: ConnectionRunClaims, failedAuthRun = false) {
|
||
let run = await db
|
||
.select({
|
||
id: heartbeatRuns.id,
|
||
companyId: heartbeatRuns.companyId,
|
||
agentId: heartbeatRuns.agentId,
|
||
status: heartbeatRuns.status,
|
||
errorCode: heartbeatRuns.errorCode,
|
||
resultJson: heartbeatRuns.resultJson,
|
||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||
nativeIssueId: heartbeatRuns.nativeIssueId,
|
||
})
|
||
.from(heartbeatRuns)
|
||
.where(eq(heartbeatRuns.id, claims.run_id))
|
||
.then((rows) => rows[0] ?? null);
|
||
if (
|
||
!run
|
||
|| run.companyId !== claims.company_id
|
||
|| run.agentId !== claims.sub
|
||
|| (!run.activeIdentityContextId && run.responsibleUserId !== claims.responsible_user_id)
|
||
) throw forbidden("Runtime tool token does not match its heartbeat run");
|
||
if (failedAuthRun ? run.status !== "failed" || !isAiAuthenticationRepairable(run) : run.status !== "running") {
|
||
throw forbidden("Runtime tool token is no longer active");
|
||
}
|
||
if (run.activeIdentityContextId && !failedAuthRun) {
|
||
const current = await captureRunIdentity(db, { companyId: run.companyId, agentId: run.agentId, runId: run.id });
|
||
run = { ...run, responsibleUserId: current.run.responsibleUserId };
|
||
}
|
||
if (!run.responsibleUserId) throw forbidden("This task needs a responsible user to connect a service");
|
||
const snapshot = record(run.contextSnapshot);
|
||
const issueId = text(snapshot?.issueId) ?? text(snapshot?.taskId) ?? run.nativeIssueId;
|
||
if (!issueId) throw unprocessable("Connection requests require a task-bound heartbeat run");
|
||
const [issue, agent, responsibleMembership] = await Promise.all([
|
||
db.select({
|
||
id: issues.id,
|
||
companyId: issues.companyId,
|
||
status: issues.status,
|
||
assigneeAgentId: issues.assigneeAgentId,
|
||
}).from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).then((rows) => rows[0] ?? null),
|
||
db.select({ id: agents.id, companyId: agents.companyId, name: agents.name })
|
||
.from(agents)
|
||
.where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)))
|
||
.then((rows) => rows[0] ?? null),
|
||
db.select({
|
||
status: companyMemberships.status,
|
||
membershipRole: companyMemberships.membershipRole,
|
||
}).from(companyMemberships).where(and(
|
||
eq(companyMemberships.companyId, run.companyId),
|
||
eq(companyMemberships.principalType, "user"),
|
||
eq(companyMemberships.principalId, run.responsibleUserId!),
|
||
)).then((rows) => rows[0] ?? null),
|
||
]);
|
||
if (!issue || !agent) throw notFound("Runtime task or agent was not found");
|
||
if (
|
||
!responsibleMembership
|
||
|| responsibleMembership.status !== "active"
|
||
|| !responsibleMembership.membershipRole
|
||
|| responsibleMembership.membershipRole === "viewer"
|
||
) {
|
||
throw forbidden("Responsible user is no longer authorized for company write access");
|
||
}
|
||
if (issue.assigneeAgentId !== agent.id) throw conflict("The requesting agent no longer owns this task");
|
||
if (issue.status === "done" || issue.status === "cancelled") {
|
||
throw conflict("Connection requests cannot be created on a closed task");
|
||
}
|
||
return { run, issue, agent };
|
||
}
|
||
|
||
async function connectionInventory(companyId: string) {
|
||
const [applications, connections] = await Promise.all([
|
||
access.listApplications(companyId),
|
||
access.listConnections(companyId),
|
||
]);
|
||
return {
|
||
applications,
|
||
connections,
|
||
applicationsById: new Map(applications.map((application) => [application.id, application] as const)),
|
||
};
|
||
}
|
||
|
||
async function managedAgent(companyId: string, agentId: string, serviceSlug: string, options: { fallback?: AiConnectionBinding; sourceRunId?: string | null } = {}, client = db) {
|
||
const [agent] = await client.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
||
if (!agent) return null;
|
||
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
if (router) {
|
||
// Repair the durable task allocation, without adopting a personal default
|
||
// or replacing the agent's pool. A changed pool invalidates the old card.
|
||
if (!options.sourceRunId) return null;
|
||
const [source] = await client.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.id, options.sourceRunId)));
|
||
const taskKey = text(source?.contextSnapshot?.aiRouterTaskKey);
|
||
const evidence = record(source?.contextSnapshot?.aiRouterSelection);
|
||
if (!source || !isAiAuthenticationFailure(source.errorCode) || source.status !== "failed" || evidence?.poolId !== router.connectionId || !taskKey) return null;
|
||
const [pin] = await client.select().from(aiConnectionTaskPins).where(and(eq(aiConnectionTaskPins.companyId, companyId), eq(aiConnectionTaskPins.poolId, router.connectionId), eq(aiConnectionTaskPins.agentId, agentId), eq(aiConnectionTaskPins.taskKey, taskKey)));
|
||
const binding = pin?.selection.binding;
|
||
if (!pin || binding?.provider !== serviceSlug || JSON.stringify(evidence.binding) !== JSON.stringify(binding)) return null;
|
||
return { agent, binding, adapterConfig: { ...agent.adapterConfig, ...pin.selection.runtimeConfig }, requiresAdoption: false };
|
||
}
|
||
const saved = aiConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
const binding = saved ?? options.fallback;
|
||
return binding?.provider === serviceSlug ? { agent, binding, adapterConfig: agent.adapterConfig, requiresAdoption: !saved } : null;
|
||
}
|
||
|
||
async function usableConnectionForAgent(input: {
|
||
companyId: string;
|
||
agentId: string;
|
||
responsibleUserId: string;
|
||
serviceSlug: string;
|
||
purpose?: "ai" | "channel";
|
||
sourceRunId?: string | null;
|
||
inventory?: Awaited<ReturnType<typeof connectionInventory>>;
|
||
}) {
|
||
const managed = input.purpose === "ai" ? await managedAgent(input.companyId, input.agentId, input.serviceSlug, { sourceRunId: input.sourceRunId }) : null;
|
||
if (managed) {
|
||
try {
|
||
const selected = await aiConnectionService(db).select({ companyId: input.companyId, agentId: input.agentId, userId: input.responsibleUserId, adapterType: managed.agent.adapterType, model: managed.adapterConfig.model, runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent, binding: managed.binding });
|
||
return access.getConnection(selected.connection.id, input.companyId);
|
||
} catch (error) { if ([403, 404, 422].includes((error as { status?: number }).status ?? 0)) return null; throw error; }
|
||
}
|
||
if (input.purpose === "ai") return null;
|
||
if (input.purpose === "channel") {
|
||
if (input.serviceSlug !== "agentmail") return null;
|
||
const inboxes = await assignedAgentmailInboxes(db, input.companyId, input.agentId);
|
||
return inboxes[0] ? access.getConnection(inboxes[0].connectionId, input.companyId) : null;
|
||
}
|
||
const inventory = input.inventory ?? await connectionInventory(input.companyId);
|
||
const matching = inventory.connections.filter((connection) =>
|
||
sourceSlugForConnection(connection, inventory.applicationsById) === input.serviceSlug
|
||
&& connection.status !== "archived" && connection.connectionPurpose !== "ai"
|
||
);
|
||
if (matching.length === 0) return null;
|
||
const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId);
|
||
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
|
||
const permittedIds = new Set(effective.allowedTools.map((tool) => tool.connectionId));
|
||
const usable = (connection: ToolConnection | undefined) => connection
|
||
&& installedIds.has(connection.id) && permittedIds.has(connection.id)
|
||
&& connection.status === "active" && connection.enabled
|
||
&& ["mcp_remote", "local_stdio"].includes(connection.transport)
|
||
&& !isToolConnectionAttentionHealth(connection.healthStatus) ? connection : null;
|
||
if (input.serviceSlug === "github") {
|
||
const selection = await resolveManagedGitHubIdentitySelection(db, input.companyId, {
|
||
agentId: input.agentId, responsibleUserId: input.responsibleUserId,
|
||
});
|
||
return usable(matching.find((connection) => connection.id === selection.grant?.connectionId));
|
||
}
|
||
const installed = matching.filter((connection) => installedIds.has(connection.id));
|
||
const grantsByConnection = await Promise.all(installed.map(async (connection) => ({
|
||
connection,
|
||
grants: (await access.listConnectionGrants(connection.id, input.companyId)).grants,
|
||
})));
|
||
|
||
// Keep readiness aligned with runtime identity resolution. A dedicated
|
||
// agent identity wins over the responsible person's personal identity,
|
||
// while an inactive or ambiguous higher-priority identity fails closed.
|
||
const dedicated = grantsByConnection.flatMap(({ connection, grants }) => grants
|
||
.filter((grant) => grant.kind === "agent" && grant.subjectAgentId === input.agentId)
|
||
.map((grant) => ({ connection, grant })));
|
||
if (dedicated.length > 0) {
|
||
const active = dedicated.filter(({ grant }) => grant.status === "active");
|
||
return active.length === 1 ? usable(active[0]!.connection) : null;
|
||
}
|
||
|
||
const personal = grantsByConnection.flatMap(({ connection, grants }) => grants
|
||
.filter((grant) => grant.kind === "user" && grant.subjectUserId === input.responsibleUserId)
|
||
.map((grant) => ({ connection, grant })));
|
||
if (personal.length > 0) {
|
||
const active = personal.filter(({ grant }) => grant.status === "active");
|
||
return active.length === 1 ? usable(active[0]!.connection) : null;
|
||
}
|
||
|
||
const organization = grantsByConnection.flatMap(({ connection, grants }) => grants
|
||
.filter((grant) => grant.kind === "organization")
|
||
.map((grant) => ({ connection, grant })));
|
||
const activeOrganization = organization.filter(({ grant }) => grant.status === "active");
|
||
return activeOrganization.length === 1 ? usable(activeOrganization[0]!.connection) : null;
|
||
}
|
||
|
||
async function administrativeDenial(companyId: string, agentId: string, serviceSlug: string, inventory: Awaited<ReturnType<typeof connectionInventory>>) {
|
||
const effective = await access.getEffectiveProfilesForAgent(companyId, agentId);
|
||
const installed = effective.installedConnections.filter((connection) => sourceSlugForConnection(connection, inventory.applicationsById) === serviceSlug);
|
||
if (!installed.length || effective.allowedTools.some((tool) => installed.some((connection) => connection.id === tool.connectionId))) return false;
|
||
for (const connection of installed) {
|
||
if ((await indexedCatalog(connection.id, companyId)).some((tool) => tool.entryKind === "tool")) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function indexedCatalog(connectionId: string, companyId: string) {
|
||
// Discovery must not contact providers or mutate their health/cache state.
|
||
return db.select().from(toolCatalogEntries).where(and(
|
||
eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.connectionId, connectionId),
|
||
eq(toolCatalogEntries.status, "active"),
|
||
));
|
||
}
|
||
|
||
// Readiness uses the same credential, feature and assignment checks as the
|
||
// runtime's AgentMail tools; it never starts a worker or contacts the provider.
|
||
function assignedAgentmailInboxes(database: Db, companyId: string, agentId: string) {
|
||
return emailChannelService(database, { heartbeat: { wakeup: async () => {
|
||
throw new Error("Connection discovery cannot start an email worker");
|
||
} } }).assignedInboxes(companyId, agentId);
|
||
}
|
||
|
||
async function resolveService(service: string, companyId: string, userId: string, agentId: string, purpose?: "ai" | "channel") {
|
||
if (!service.startsWith("connection:")) {
|
||
const app = getAppStoreDefinition(service);
|
||
if (!app) throw notFound("Connection service was not found");
|
||
if (purpose === "channel" && service !== "agentmail") {
|
||
throw unprocessable("This email connection is not available");
|
||
}
|
||
const methods = purpose === "ai" ? getAvailableConnectionMethods(app).filter(method => method.transport === "runtime_auth")
|
||
: purpose === "channel" ? getAvailableConnectionMethods(app).filter(method => method.purpose === "channel")
|
||
: availableToolConnectionMethods(app);
|
||
return { ...app, available: app.availability?.available !== false,
|
||
searchCapabilities: methods.map((method) =>
|
||
`${method.whenToUse} ${method.capabilityProfile?.label ?? ""} ${method.capabilityProfile?.description ?? ""}`).join(" "),
|
||
methods: methods.map((method) => ({
|
||
key: method.key, label: method.label ?? method.key, auth: method.auth,
|
||
})), source: "catalog" as const };
|
||
}
|
||
const id = service.slice("connection:".length);
|
||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||
throw notFound("Configured connection was not found");
|
||
}
|
||
const connection = await access.getConnection(id, companyId);
|
||
if (connection.connectionPurpose === "ai" && purpose !== "ai") throw notFound("AI authentication is not a tool connection");
|
||
const { grants } = await access.listConnectionGrants(id, companyId);
|
||
if (connection.status === "archived" || !grants.some((grant) => grant.status === "active" && (
|
||
grant.kind === "organization" || (grant.kind === "user" && grant.subjectUserId === userId)
|
||
|| (grant.kind === "agent" && grant.subjectAgentId === agentId)
|
||
))) throw notFound("Configured connection was not found");
|
||
const application = await access.getApplication(connection.applicationId, companyId);
|
||
return {
|
||
slug: service, name: connection.name, description: application.description, searchCapabilities: "",
|
||
branding: { logoUrl: undefined, darkLogoUrl: undefined },
|
||
available: connection.enabled,
|
||
methods: [{ key: "configured", label: "Use configured connection", auth:
|
||
connection.authKind === "oauth" ? "oauth" as const : connection.authKind === "none" ? "none" as const : "api_key" as const }],
|
||
source: "configured" as const,
|
||
};
|
||
}
|
||
|
||
async function search(claims: ConnectionRunClaims, query: string, options: { retryProviderChoice?: boolean } = {}): Promise<ConnectionsSearchResult> {
|
||
const { run, agent, issue } = await loadRunContext(claims);
|
||
const settings = await instanceSettingsService(db).getExperimental();
|
||
const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, run.companyId));
|
||
if (!company) throw notFound("Company was not found");
|
||
const explicit = explicitAggregatorQuery(query);
|
||
const serviceQuery = explicit?.serviceQuery ?? query;
|
||
const preparedQuery = prepareConnectionSearch(serviceQuery);
|
||
const inventory = await connectionInventory(run.companyId);
|
||
const candidates: Array<{ item: ConnectionSearchResultItem; score: number; nameScore: number }> = [];
|
||
const authorizedCatalogs = new Map<string, Awaited<ReturnType<typeof indexedCatalog>>>();
|
||
const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app)
|
||
.filter(method => method.purpose !== "channel" || app.slug === "agentmail"
|
||
|| (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors));
|
||
const services = [...APP_STORE_DEFINITIONS.filter(app => discoveryMethods(app).length).map((app) => app.slug),
|
||
...inventory.connections.filter((connection) =>
|
||
sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:")
|
||
&& connection.status !== "archived").map((connection) => `connection:${connection.id}`)];
|
||
for (const service of services) {
|
||
let app;
|
||
try { app = await resolveService(service, run.companyId, run.responsibleUserId!, agent.id); }
|
||
catch (error) { if (service.startsWith("connection:") && (error as { status?: number }).status === 404) continue; throw error; }
|
||
const definition = getAppStoreDefinition(service);
|
||
if (definition) {
|
||
const methods = discoveryMethods(definition);
|
||
app = { ...app, searchCapabilities: methods.map(method => `${method.whenToUse} ${method.label ?? ""} ${method.capabilityProfile?.description ?? ""}`).join(" "),
|
||
methods: methods.map(method => ({ key: method.key, label: method.label ?? method.key, auth: method.auth,
|
||
purpose: method.purpose ?? "tool",
|
||
...(method.purpose === "channel" && method.provider ? {
|
||
setupPath: `/${company.prefix}/apps/chat/connect?${new URLSearchParams({ provider: method.provider, purpose: "chat", agentId: agent.id })}`,
|
||
} : method.purpose === "ai" ? { setupPath: `/${company.prefix}/apps/connect?${new URLSearchParams({ source: service })}` } : {}),
|
||
})),
|
||
};
|
||
}
|
||
const aiOnly = definition && discoveryMethods(definition).every(method => method.purpose === "ai");
|
||
const matching = inventory.connections.filter((connection) =>
|
||
sourceSlugForConnection(connection, inventory.applicationsById) === service && connection.status !== "archived"
|
||
&& (aiOnly ? connection.connectionPurpose === "ai" : connection.connectionPurpose !== "ai"));
|
||
// Indexed descriptions can contain private workspace metadata, including
|
||
// for catalog providers. Check each configured connection's audience first.
|
||
const catalogs = await Promise.all(matching.map(async (connection) => {
|
||
const { grants } = await access.listConnectionGrants(connection.id, run.companyId);
|
||
const authorized = grants.some((grant) => grant.status === "active" && (
|
||
grant.kind === "organization" || (grant.kind === "user" && grant.subjectUserId === run.responsibleUserId)
|
||
|| (grant.kind === "agent" && grant.subjectAgentId === agent.id)
|
||
));
|
||
const entries = authorized ? await indexedCatalog(connection.id, run.companyId) : [];
|
||
authorizedCatalogs.set(connection.id, entries);
|
||
return entries;
|
||
}));
|
||
const catalog = catalogs.flat().filter((entry) => entry.status === "active");
|
||
const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name],
|
||
`${app.description ?? ""} ${app.searchCapabilities} ${catalog.map(tool => `${tool.toolName} ${tool.description ?? ""}`).join(" ")}`);
|
||
if (!score) continue;
|
||
const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id,
|
||
responsibleUserId: run.responsibleUserId!, serviceSlug: service, purpose: aiOnly ? "ai" : service === "agentmail" ? "channel" : undefined, inventory });
|
||
const denied = !aiOnly && !ready && matching.length > 0 && await administrativeDenial(run.companyId, agent.id, service, inventory);
|
||
candidates.push({ score, nameScore, item: {
|
||
service, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null,
|
||
methods: app.methods, source: app.source,
|
||
state: ready ? "ready" : (denied && !isRemoteMcpConnectorId(service)) || !app.available || !app.methods.length ? "unavailable"
|
||
: matching.length ? "needs_user_action" : "available",
|
||
reason: ready ? "Connection is installed and usable by this agent" : denied ? "Ask the responsible user to grant this agent access with connection_request" : !app.available ? "Connection is disabled or unavailable"
|
||
: matching.some((connection) => isToolConnectionAttentionHealth(connection.healthStatus)) ? "Connection needs attention"
|
||
: matching.length ? "Review identity and access for this agent" : "Connect this service to continue",
|
||
connectionId: ready?.id ?? null,
|
||
}});
|
||
}
|
||
const publicMatches = searchAggregatorServices(preparedQuery);
|
||
const bestMatches = publicMatches.filter(match => Math.floor(match.nameScore / 100) === Math.floor((publicMatches[0]?.nameScore ?? 0) / 100));
|
||
const namedPublicService = findAggregatorService(serviceQuery, publicMatches);
|
||
const publicService = namedPublicService ?? (bestMatches.length === 1 ? bestMatches[0]!.service : undefined);
|
||
// Extract service namespaces only from the current identity's indexed tools.
|
||
// Generic provider search/execute descriptions do not prove app support.
|
||
const indexedServices = new Set<string>();
|
||
for (const connection of inventory.connections) {
|
||
const provider = sourceSlugForConnection(connection, inventory.applicationsById);
|
||
if (!isRemoteMcpConnectorId(provider) || !connection.enabled || connection.status === "archived") continue;
|
||
for (const entry of authorizedCatalogs.get(connection.id) ?? []) {
|
||
const namespace = entry.toolName.toLowerCase().match(/^([a-z0-9-]+)[_.:]/)?.[1];
|
||
if (namespace && !isRemoteMcpConnectorId(namespace)) indexedServices.add(namespace);
|
||
if (provider === "executor" && entry.toolName === "execute") {
|
||
for (const line of (entry.description ?? "").split("\n")) {
|
||
const slug = line.trim().match(/^- `([a-z0-9][a-z0-9-]{0,79})`$/)?.[1];
|
||
if (slug) indexedServices.add(slug);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const indexedMatches = [...indexedServices].map(slug => ({ slug, ...scoreConnectionSearch(preparedQuery, [slug]) }))
|
||
.filter(match => match.nameScore > 0).sort((a, b) => b.score - a.score);
|
||
const bestExternalTier = Math.floor(Math.max(publicMatches[0]?.nameScore ?? 0, indexedMatches[0]?.nameScore ?? 0) / 100);
|
||
// Native preference applies to the same app or equally strong name matches.
|
||
// A typo match such as Notion must not hide the distinctly named app Motion.
|
||
const exact = candidates.filter(({ item, nameScore }) => ((nameScore >= 200 && Math.floor(nameScore / 100) >= bestExternalTier) || item.service === publicService?.slug)
|
||
&& (!isRemoteMcpConnectorId(item.service) || (!publicService && !indexedMatches.length)));
|
||
const ranked = () => candidates.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name))
|
||
.slice(0, 40).map(({ item }) => item);
|
||
const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-"));
|
||
// Indexed-only app labels must be identical when requests re-search the slug.
|
||
const targetName = publicService?.name ?? targetService.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||
const previous = await providerSelections(run.companyId, issue.id, agent.id, run.responsibleUserId!, `connection-provider:${targetService}`);
|
||
const explicitConsent = explicit && await hasExplicitProviderRequest(run.companyId, issue.id, run.responsibleUserId!, targetService, explicit.provider, previous[0]);
|
||
if (exact.length && (!explicitConsent || exact.some(({ item }) => item.state === "unavailable"))) {
|
||
const otherApps = bestMatches.filter(({ service, nameScore }) => nameScore >= 200 && !exact.some(({ item }) =>
|
||
item.service === service.slug || scoreConnectionSearch(service.name, [item.name]).nameScore === 1000));
|
||
if (otherApps.length && !exact.some(({ item }) => item.state === "unavailable")) {
|
||
const otherRoutes = (await Promise.all(otherApps.slice(0, 10).map(({ service }) =>
|
||
aggregatorAlternatives(service.slug, service.name, service)))).flat();
|
||
if (otherRoutes.length) return discoverySuggestions([...ranked(), ...otherRoutes]);
|
||
}
|
||
return directSearchResult(query, ranked());
|
||
}
|
||
async function aggregatorAlternatives(targetService: string, targetName: string, publicService?: AggregatorServiceDefinition) {
|
||
const alternatives: ConnectionSearchResultItem[] = [];
|
||
if (/^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) {
|
||
for (const provider of AGGREGATOR_PRIORITY) {
|
||
// Broad execute/search descriptions are not evidence of app support. Only a
|
||
// namespaced action (or an explicitly listed Executor integration) qualifies.
|
||
const connections = inventory.connections.filter(connection => connection.status !== "archived" && connection.enabled && sourceSlugForConnection(connection, inventory.applicationsById) === provider);
|
||
let indexedAt: Date | undefined;
|
||
for (const connection of connections) {
|
||
const { grants } = await access.listConnectionGrants(connection.id, run.companyId);
|
||
if (!grants.some(grant => grant.status === "active" && (grant.kind === "organization"
|
||
|| grant.kind === "user" && grant.subjectUserId === run.responsibleUserId
|
||
|| grant.kind === "agent" && grant.subjectAgentId === agent.id))) continue;
|
||
const entries = authorizedCatalogs.get(connection.id) ?? [];
|
||
const matching = entries.filter(entry => {
|
||
const name = entry.toolName.toLowerCase();
|
||
return name.startsWith(targetService + "_") || name.startsWith(targetService + ".") || name.startsWith(targetService + ":")
|
||
|| provider === "executor" && entry.toolName === "execute"
|
||
&& (entry.description ?? "").split("\n").some(line => line.trim() === "- `" + targetService + "`");
|
||
});
|
||
for (const entry of matching) if (!indexedAt || entry.lastSeenAt > indexedAt) indexedAt = entry.lastSeenAt;
|
||
}
|
||
const publishedAt = publicService ? (publicService.providers as Partial<Record<string, string>>)[provider] : undefined;
|
||
const published = Boolean(publishedAt);
|
||
if (!published && !indexedAt) continue;
|
||
if (await administrativeDenial(run.companyId, agent.id, provider, inventory)) continue;
|
||
const app = await resolveService(provider, run.companyId, run.responsibleUserId!, agent.id);
|
||
if (!app.available || !app.methods.length) continue;
|
||
const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: provider, inventory });
|
||
alternatives.push({
|
||
service: `via:${provider}:${targetService}`, name: `${targetName} through ${app.name}`,
|
||
source: "aggregator", state: "available", description: `Connect ${targetName} through ${app.name}, an external service.`,
|
||
logoUrl: app.branding.logoUrl ?? null, methods: app.methods, connectionId: ready?.id ?? null,
|
||
reason: ready ? `${app.name} is connected; verify ${targetName} authorization and the requested action.`
|
||
: provider === "arcade" ? "Set up an Arcade gateway with this app's tools, then authorize the app."
|
||
: `Connect ${app.name}, then verify and authorize ${targetName}.`,
|
||
aggregator: { provider, targetService, targetName, evidenceUrl: published ? publicService?.evidenceUrls?.[provider] ?? AGGREGATOR_CATALOG_SOURCES[provider] ?? null : null,
|
||
verifiedAt: publishedAt ?? indexedAt!.toISOString(),
|
||
readiness: ready ? "requires_app_verification" : "requires_provider_setup" },
|
||
});
|
||
}
|
||
}
|
||
return alternatives;
|
||
}
|
||
const alternatives = await aggregatorAlternatives(targetService, targetName, publicService);
|
||
// A typo/prefix match is a discovery hint, not a selected app. Generic
|
||
// capabilities (e.g. "pages") can resemble a catalog name ("Page X").
|
||
// Retain authorized installed matches instead of prescribing provider consent.
|
||
const namedTarget = namedPublicService !== undefined
|
||
|| indexedMatches.some(match => match.slug === targetService && match.nameScore >= 500);
|
||
if (alternatives.length && !namedTarget) {
|
||
return discoverySuggestions([...ranked(), ...alternatives]);
|
||
}
|
||
if (explicit && explicitConsent) {
|
||
const selected = alternatives.find(item => item.aggregator?.provider === explicit.provider);
|
||
if (!selected) return { version: 1, query, results: [], instruction: "The explicitly requested external provider is unavailable or its app support could not be verified. Explain the limitation. Do not switch providers automatically." };
|
||
return { version: 1, query, results: [{ ...selected, service: explicit.provider }],
|
||
instruction: `The user explicitly requested ${AGGREGATOR_NAMES[explicit.provider]}. Disclose that this external service handles the connection and requests to ${targetName}. Do not ask another provider-choice question or substitute another provider. Call connection_request with service ${explicit.provider} and targetService ${targetService}. Follow its returned instruction; app authorization is not yet verified.` };
|
||
}
|
||
if (!alternatives.length && bestMatches.length > 1) {
|
||
const matches = (await Promise.all(bestMatches.slice(0, 10).map(({ service }) =>
|
||
aggregatorAlternatives(service.slug, service.name, service)))).flat();
|
||
if (matches.length) return discoverySuggestions([...ranked(), ...matches]);
|
||
}
|
||
if (!alternatives.length) return directSearchResult(query, ranked(), true);
|
||
const question = aggregatorProviderQuestion(targetService, targetName, alternatives);
|
||
const latest = previous[0];
|
||
if (latest && (latest.status === "pending" || !options.retryProviderChoice)) {
|
||
if (latest.status === "pending") return { version: 1, query, results: alternatives, instruction: "The provider-choice question is already pending. Finish independent work, then yield. Do not ask again.", selectionInteractionId: latest.id };
|
||
const choice = selectedProvider(latest, question);
|
||
if (choice === "none" || latest.status === "cancelled") return { version: 1, query, results: [], instruction: "The user declined external providers for this service. Do not connect or ask again. Explain that access remains unavailable. Only if the user explicitly asks to reconsider, search again with retryProviderChoice true." };
|
||
const selected = alternatives.find(item => item.service === choice);
|
||
if (selected) return { version: 1, query, results: [selected], selectionInteractionId: latest.id,
|
||
instruction: `The user selected ${selected.name}. Call connection_request with service ${selected.service} and selectionInteractionId ${latest.id}. Follow its returned instruction; underlying app access is not yet verified.` };
|
||
if (choice) return { version: 1, query, results: [], instruction: "The chosen external provider is no longer available for this service. Explain the restriction or missing support. Do not switch providers automatically. The user may explicitly request a new provider choice." };
|
||
}
|
||
// An agent's query is not proof of human choice. Keep its named provider as
|
||
// the sole confirmation option unless a saved user message proves consent.
|
||
const offered = explicit ? alternatives.filter(item => item.aggregator?.provider === explicit.provider) : alternatives;
|
||
if (!offered.length) return { version: 1, query, results: [], instruction: "The requested external provider is unavailable. Do not switch providers automatically." };
|
||
const providerQuestion = aggregatorProviderQuestion(targetService, targetName, offered);
|
||
return { version: 1, query, results: offered, providerQuestion, providerQuestionSet: aggregatorProviderQuestionSet(providerQuestion),
|
||
instruction: "No matching built-in Paperclip connection was found. Ask the responsible user with providerQuestion exactly as returned (including its id, full prompt, and options). With native request_human_input, use interactionKind questions, continuationPolicy wake_assignee, and payload {version:1, questionSet:providerQuestionSet} exactly as returned. Otherwise use ask_user_questions with payload {version:1, questions:[providerQuestion]}. These are external services. Wait for the saved answer; then call connection_request with the selected service identifier and selectionInteractionId set to the answered question interaction ID. None for now means do not connect. Do not claim app access yet." };
|
||
|
||
function discoverySuggestions(results: ConnectionSearchResultItem[]): ConnectionsSearchResult {
|
||
return { version: 1, query, results: results.slice(0, 40),
|
||
instruction: "These are possible connection matches. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods and AgentMail use connection_request to show the inline setup card; other channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." };
|
||
}
|
||
}
|
||
|
||
function directSearchResult(query: string, results: ConnectionSearchResultItem[], suggestions = false): ConnectionsSearchResult {
|
||
return { version: 1, query, results, instruction: !results.length
|
||
? "No verified connection route was found. Explain that support could not be verified; do not invent a provider route or request unsupported services."
|
||
: !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable"
|
||
? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}`
|
||
: !suggestions && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||
: suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For available or needs_user_action tool methods or AgentMail, call connection_request with the service identifier to present its setup card. For other available or needs_user_action channel or AI methods, share the method's setupPath. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support."
|
||
: results[0]!.state === "ready" && results[0]!.methods.every(method => method.purpose === "ai")
|
||
? "AI authentication is available for this agent's next execution. Do not create another connection request or ask the user to reconnect."
|
||
: results[0]!.service === "agentmail"
|
||
? results[0]!.state === "ready"
|
||
? "An active AgentMail inbox is assigned to you. Use agentmail_inboxes to read its address. Do not request another connection."
|
||
: "Call connection_request with service agentmail to show the inline API-key card. Do not send a setup link or ask for the key in chat. The card creates an inbox for this agent; wait for completion before claiming an email address."
|
||
: results[0]!.methods.every(method => method.purpose && method.purpose !== "tool")
|
||
? "Choose the method relevant to the task using its purpose and label. Share its setupPath with the user to open the existing channel or AI setup flow. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes."
|
||
: results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request."
|
||
: results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||
: "Call connection_request with the returned service identifier. The user already asked to connect: do not ask a generic confirmation or imitate the setup card. Follow the returned instruction." };
|
||
}
|
||
|
||
async function providerSelections(companyId: string, issueId: string, agentId: string, userId: string, questionId: string) {
|
||
const rows = await db.select().from(issueThreadInteractions).where(and(
|
||
eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.issueId, issueId),
|
||
eq(issueThreadInteractions.kind, "ask_user_questions"), eq(issueThreadInteractions.createdByAgentId, agentId),
|
||
)).orderBy(desc(issueThreadInteractions.createdAt));
|
||
return rows.filter(row => (!row.addresseeUserId || row.addresseeUserId === userId)
|
||
&& (!row.resolvedByUserId || row.resolvedByUserId === userId)
|
||
&& askUserQuestionsPayloadSchema.safeParse(row.payload).success
|
||
&& (row.payload as { questions: Array<{ id: string }> }).questions.some(question => question.id === questionId));
|
||
}
|
||
|
||
async function hasExplicitProviderRequest(companyId: string, issueId: string, userId: string, target: string, provider: string, latest?: typeof issueThreadInteractions.$inferSelect) {
|
||
if (latest?.status === "pending") return false;
|
||
// Use a persisted human-authored message, never the agent-supplied query or
|
||
// mutable task description. Conservative parsing falls back to confirmation.
|
||
const [message] = await db.select().from(issueComments).where(and(
|
||
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
||
eq(issueComments.authorUserId, userId), isNull(issueComments.authorAgentId),
|
||
isNull(issueComments.createdByRunId), isNull(issueComments.derivedAuthorAgentId), isNull(issueComments.deletedAt),
|
||
)).orderBy(desc(issueComments.createdAt), desc(issueComments.id)).limit(1);
|
||
if (!message || !/^(?:please\s+)?(?:connect|use)\b/i.test(message.body.trim())) return false;
|
||
if (latest && message.createdAt <= (latest.resolvedAt ?? latest.createdAt)) return false;
|
||
const request = explicitAggregatorQuery(message.body);
|
||
if (request?.provider !== provider) return false;
|
||
const known = findAggregatorService(target);
|
||
const requested = normalizeConnectionQuery(request.serviceQuery).replace(/^(?:please )?(?:connect|use) (?:to )?/, "");
|
||
const names = known ? [known.slug, known.name, ...known.aliases] : [target];
|
||
return names.some(name => requested === normalizeConnectionQuery(name));
|
||
}
|
||
|
||
function selectedProvider(row: typeof issueThreadInteractions.$inferSelect, expected: ReturnType<typeof aggregatorProviderQuestion>) {
|
||
if (row.status !== "answered" || !row.resolvedByUserId || row.resolvedByAgentId) return null;
|
||
const payload = askUserQuestionsPayloadSchema.safeParse(row.payload);
|
||
const result = askUserQuestionsResultSchema.safeParse(row.result);
|
||
if (!payload.success || !result.success) return null;
|
||
const question = payload.data.questions.find(q => q.id === expected.id);
|
||
const answer = result.data.answers.find(answer => answer.questionId === expected.id);
|
||
if (!question || question.selectionMode !== "single" || !answer || answer.optionIds.length !== 1 || answer.otherText) return null;
|
||
const selected = answer.optionIds[0]!;
|
||
const option = question.options.find(option => option.id === selected);
|
||
// A decline survives copy/catalog revisions; it can never authorize a call.
|
||
if (selected === "none" && option?.label === "None for now") return selected;
|
||
if (question.prompt !== expected.prompt || question.helpText !== expected.helpText) return null;
|
||
const route = parseAggregatorRoute(selected);
|
||
if (!route || expected.id !== `connection-provider:${route.targetService}`) return null;
|
||
const providerName = AGGREGATOR_NAMES[route.provider];
|
||
if (option?.label !== providerName && option?.label !== `${providerName} — Recommended`) return null;
|
||
// Eligibility is checked separately. A changed recommendation order must not
|
||
// erase the human's choice or silently replace it with another provider.
|
||
return selected;
|
||
}
|
||
|
||
async function request(
|
||
claims: ConnectionRunClaims,
|
||
serviceSlug: string,
|
||
options: { purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string; connectionId?: string; toolNames?: string[] } = {},
|
||
): Promise<ConnectionRequestResult> {
|
||
const context = await loadRunContext(claims);
|
||
return requestWithContext(context, serviceSlug, options);
|
||
}
|
||
|
||
async function requestWithContext(
|
||
context: Awaited<ReturnType<typeof loadRunContext>>,
|
||
serviceSlug: string,
|
||
options: { purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string; connectionId?: string; toolNames?: string[] } = {},
|
||
): Promise<ConnectionRequestResult> {
|
||
const claims = { sub: context.agent.id, company_id: context.run.companyId, run_id: context.run.id, responsible_user_id: context.run.responsibleUserId! };
|
||
const route = parseAggregatorRoute(serviceSlug);
|
||
let upstreamService: { slug: string; name: string; selectionInteractionId?: string } | undefined;
|
||
if (options.targetService) {
|
||
if (route || options.purpose || !isRemoteMcpConnectorId(serviceSlug)) throw unprocessable("An explicit target app requires a direct external-provider request");
|
||
const found = await search(claims, `${options.targetService} through ${serviceSlug}`);
|
||
const selected = found.results.find(item => item.aggregator?.provider === serviceSlug && (item.service === serviceSlug || Boolean(found.selectionInteractionId && !found.providerQuestion)));
|
||
if (!selected?.aggregator) throw forbidden("The requested provider cannot connect this app without verified support and a recorded user choice or explicit user request");
|
||
// Reuse the saved-answer validation below; a pending question also carries
|
||
// an interaction ID, but is never permission to create the setup card.
|
||
if (selected.service !== serviceSlug) return request(claims, selected.service, { ...options, targetService: undefined, selectionInteractionId: found.selectionInteractionId });
|
||
upstreamService = { slug: selected.aggregator.targetService, name: selected.aggregator.targetName };
|
||
}
|
||
if (route) {
|
||
if (options.purpose) throw unprocessable("Aggregator routes are tool connections only");
|
||
const found = await search(claims, route.targetService);
|
||
const selected = found.results.find(item => item.service === serviceSlug);
|
||
if (!selected?.aggregator || !options.selectionInteractionId || found.selectionInteractionId !== options.selectionInteractionId) {
|
||
throw forbidden("Choose an external provider using the returned provider question before requesting this connection");
|
||
}
|
||
const rows = await providerSelections(context.run.companyId, context.issue.id, context.agent.id, context.run.responsibleUserId!, `connection-provider:${route.targetService}`);
|
||
const row = rows.find(row => row.id === options.selectionInteractionId);
|
||
if (!row || row.status !== "answered" || row.resolvedByUserId !== context.run.responsibleUserId || row.resolvedByAgentId
|
||
|| found.results.length !== 1 || found.providerQuestion) throw forbidden("The provider choice is not a valid saved user answer");
|
||
upstreamService = { slug: route.targetService, name: selected.aggregator.targetName, selectionInteractionId: options.selectionInteractionId };
|
||
serviceSlug = route.provider;
|
||
}
|
||
// AgentMail has one channel method. Infer it from the server-owned catalog
|
||
// identifier instead of asking the model to invent a new tool argument.
|
||
if (serviceSlug === "agentmail" && !options.purpose) options = { ...options, purpose: "channel" };
|
||
const app = await resolveService(serviceSlug, context.run.companyId, context.run.responsibleUserId!, context.agent.id, options.purpose);
|
||
if (!app.available || app.methods.length === 0) {
|
||
throw unprocessable(`Connection service ${serviceSlug} is not available`);
|
||
}
|
||
let accessRequest: ConnectionIntentInteraction["payload"]["accessRequest"];
|
||
if (!options.purpose) {
|
||
const inventory = await connectionInventory(context.run.companyId);
|
||
const matching = inventory.connections.filter(connection =>
|
||
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
|
||
&& (!options.connectionId || connection.id === options.connectionId)
|
||
&& connection.connectionPurpose !== "ai" && connection.status === "active" && connection.enabled
|
||
&& !isToolConnectionAttentionHealth(connection.healthStatus));
|
||
const eligible = (await Promise.all(matching.map(async connection => {
|
||
const { grants } = await access.listConnectionGrants(connection.id, context.run.companyId);
|
||
return grants.some(grant => grant.status === "active" && (
|
||
grant.kind === "organization" || grant.subjectUserId === context.run.responsibleUserId
|
||
|| (grant.kind === "agent" && grant.subjectAgentId === context.agent.id))) ? connection : null;
|
||
}))).filter((connection): connection is ToolConnection => Boolean(connection));
|
||
if (options.connectionId && !eligible.length) throw notFound("The saved connection is not eligible for this request");
|
||
if (eligible.length === 1) {
|
||
const connection = eligible[0]!;
|
||
const catalog = (await indexedCatalog(connection.id, context.run.companyId)).filter(tool => tool.entryKind === "tool");
|
||
const names = options.toolNames ?? (app.slug === "composio" ? ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] : catalog.map(tool => tool.toolName));
|
||
const tools = names.map(name => catalog.find(tool => tool.toolName === name));
|
||
if (options.toolNames && tools.some(tool => !tool)) {
|
||
// Return discovery metadata only after validating this connection's
|
||
// eligibility. Never substitute guessed names or grant access here.
|
||
const availableNames = catalog.map(tool => tool.toolName).sort();
|
||
throw unprocessable(`A requested tool is not in this connection's active catalog. Available indexed tool names (first ${Math.min(20, availableNames.length)} of ${availableNames.length}): ${JSON.stringify(availableNames.slice(0, 20))}. Request only the needed exact names with connection_request. No access was granted.`);
|
||
}
|
||
const effective = await access.getEffectiveProfilesForAgent(context.run.companyId, context.agent.id);
|
||
const installed = effective.installedConnections.some(item => item.id === connection.id);
|
||
const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id));
|
||
if (missing && tools.length && tools.every(tool => Boolean(tool))) {
|
||
if (tools.length > 20) throw unprocessable("Specify the tools needed for this task (up to 20)");
|
||
accessRequest = {
|
||
connectionId: connection.id, connectionName: connection.name,
|
||
tools: tools.map(tool => ({ catalogEntryId: tool!.id, toolName: tool!.toolName, versionHash: tool!.versionHash,
|
||
permission: tool!.riskLevel === "read" ? "allowed" as const : "ask_first" as const }))
|
||
.sort((a, b) => Number(a.permission === "ask_first") - Number(b.permission === "ask_first") || a.toolName.localeCompare(b.toolName)),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
const ready = await usableConnectionForAgent({
|
||
companyId: context.run.companyId,
|
||
agentId: context.agent.id,
|
||
responsibleUserId: context.run.responsibleUserId!,
|
||
serviceSlug: app.slug,
|
||
purpose: options.purpose,
|
||
sourceRunId: context.run.id,
|
||
});
|
||
if (ready && !accessRequest && (!options.connectionId || ready.id === options.connectionId)) {
|
||
return {
|
||
version: 1,
|
||
service: app.slug,
|
||
state: "ready",
|
||
connectionId: ready.id,
|
||
interactionId: null,
|
||
instruction: isRemoteMcpConnectorId(app.slug) ? aggregatorContinuationInstruction(app.slug, upstreamService?.name ?? "The requested app") : options.purpose === "channel" ? "An active AgentMail inbox is assigned to you. Use agentmail_inboxes to read its address; do not request another connection." : options.purpose === "ai" ? `${app.name} authentication is available for the next execution.` : `${app.name} is connected. Use its installed tools; a native continuation will refresh tools if needed.`,
|
||
};
|
||
}
|
||
// Missing profile entries can be reviewed in a scoped access card. Acceptance
|
||
// still revalidates every tool and refuses explicit policy denials.
|
||
if (!options.purpose && !accessRequest && await administrativeDenial(context.run.companyId, context.agent.id, app.slug, await connectionInventory(context.run.companyId))) {
|
||
throw forbidden("This agent has no permitted actions for this service. Ask an administrator to review tool permissions; reconnecting will not remove a denial.");
|
||
}
|
||
const outcomeId = context.run.contextSnapshot?.interactionId;
|
||
if (typeof outcomeId === "string") {
|
||
const [outcome] = await db.select().from(issueThreadInteractions).where(and(eq(issueThreadInteractions.id, outcomeId), eq(issueThreadInteractions.companyId, context.run.companyId), eq(issueThreadInteractions.issueId, context.issue.id)));
|
||
if (outcome?.kind === "connection_intent" && outcome.status === "rejected" && connectionIntentPayloadSchema.parse(outcome.payload).serviceSlug === app.slug && connectionIntentPayloadSchema.parse(outcome.payload).purpose === options.purpose) {
|
||
throw conflict("The user declined this connection. Pursue alternatives; do not request it again in this continuation.");
|
||
}
|
||
}
|
||
const interaction = await interactions.createConnectionIntent(
|
||
context.issue,
|
||
{
|
||
payload: {
|
||
version: 1,
|
||
...(accessRequest ? { accessRequest } : {}),
|
||
serviceSlug: app.slug,
|
||
...(options.purpose ? { purpose: options.purpose } : {}),
|
||
serviceName: accessRequest ? app.name : upstreamService ? `${upstreamService.name} through ${app.name}` : app.name,
|
||
...(upstreamService ? { upstreamService } : {}),
|
||
serviceLogoUrl: app.branding.logoUrl ?? null,
|
||
serviceDarkLogoUrl: app.branding.darkLogoUrl ?? null,
|
||
requestingAgentId: context.agent.id,
|
||
requestingAgentName: context.agent.name,
|
||
phase: "requested",
|
||
},
|
||
sourceRunId: context.run.id,
|
||
sourceIdentityContextId: context.run.activeIdentityContextId,
|
||
addresseeUserId: context.run.responsibleUserId!,
|
||
idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}${upstreamService ? `:${upstreamService.slug}` : ""}${options.purpose ? `:${options.purpose}` : ""}${accessRequest ? `:access:${createHash("sha256").update(JSON.stringify(accessRequest)).digest("hex")}` : ""}`,
|
||
},
|
||
);
|
||
if (interaction.status !== "pending") throw conflict("This connection request has already been resolved. Follow its recorded outcome.");
|
||
await logActivity(db, {
|
||
companyId: context.run.companyId, actorType: "agent", actorId: context.agent.id,
|
||
agentId: context.agent.id, runId: context.run.id,
|
||
action: "issue.thread_interaction_created", entityType: "issue", entityId: context.issue.id,
|
||
details: { interactionId: interaction.id, interactionKind: "connection_intent", purpose: options.purpose },
|
||
});
|
||
return {
|
||
version: 1,
|
||
service: app.slug,
|
||
state: "needs_user_action",
|
||
connectionId: null,
|
||
interactionId: interaction.id,
|
||
instruction: `A connection card was sent to the responsible user. Finish independent work, then yield and wait for continuation. Do not repeat this request.`,
|
||
};
|
||
}
|
||
|
||
async function loadIntent(interactionId: string) {
|
||
const row = await db
|
||
.select({ interaction: issueThreadInteractions, issue: issues })
|
||
.from(issueThreadInteractions)
|
||
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
|
||
.where(eq(issueThreadInteractions.id, interactionId))
|
||
.then((rows) => rows[0] ?? null);
|
||
if (!row || row.interaction.kind !== "connection_intent") throw notFound("Connection intent not found");
|
||
const interaction = await interactions.getForIssue(row.issue, interactionId) as ConnectionIntentInteraction;
|
||
return { ...row, interaction };
|
||
}
|
||
|
||
async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise<ConnectionIntentSetupOptions> {
|
||
const loaded = await loadIntent(interactionId);
|
||
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
|
||
const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId, payload.purpose);
|
||
if (payload.purpose === "channel") {
|
||
const [saved] = await db.select().from(toolConnections).where(and(
|
||
eq(toolConnections.companyId, loaded.issue.companyId),
|
||
eq(toolConnections.uid, `agentmail-account-${interactionId}`),
|
||
));
|
||
// A retry may recover only this card's account, after checking the
|
||
// addressed person's current access. No arbitrary connection is selected.
|
||
const credential = saved ? await emailConnectionService(db).get(loaded.issue.companyId, saved.id, { userId: loaded.interaction.addresseeUserId! }) : null;
|
||
const inboxes = await assignedAgentmailInboxes(db, loaded.issue.companyId, payload.requestingAgentId);
|
||
return {
|
||
version: 1, interaction: loaded.interaction,
|
||
service: { service: app.slug, name: app.name, description: app.description ?? null,
|
||
logoUrl: app.branding.logoUrl ?? null, methods: app.methods, source: app.source,
|
||
state: "needs_user_action", connectionId: null },
|
||
existingConnections: [], requestedAgentId: payload.requestingAgentId,
|
||
emailSetup: { credentialConnectionId: credential?.id ?? null,
|
||
readyConnectionId: inboxes.find(inbox => inbox.id === interactionId)?.connectionId ?? null },
|
||
};
|
||
}
|
||
let managed = payload.purpose === "ai" ? await managedAgent(loaded.issue.companyId, payload.requestingAgentId, app.slug, { sourceRunId: loaded.interaction.sourceRunId }) : null;
|
||
if (payload.purpose === "ai" && !managed) {
|
||
const [source] = await db.select().from(heartbeatRuns).where(and(
|
||
eq(heartbeatRuns.id, loaded.interaction.sourceRunId!), eq(heartbeatRuns.companyId, loaded.issue.companyId),
|
||
eq(heartbeatRuns.agentId, payload.requestingAgentId),
|
||
));
|
||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId)));
|
||
if (source?.status === "failed" && isAiAuthenticationRepairable(source) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) {
|
||
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, source) });
|
||
}
|
||
}
|
||
if (payload.purpose === "ai" && !managed) throw conflict("The agent’s AI configuration changed. Start a new execution.");
|
||
const inventory = await connectionInventory(loaded.issue.companyId);
|
||
let usableAiConnection = managed ? await usableConnectionForAgent({
|
||
companyId: loaded.issue.companyId, agentId: payload.requestingAgentId,
|
||
responsibleUserId: loaded.interaction.addresseeUserId!, serviceSlug: app.slug, purpose: "ai", sourceRunId: loaded.interaction.sourceRunId,
|
||
}) : null;
|
||
if (managed?.requiresAdoption) {
|
||
try {
|
||
const selected = await aiConnectionService(db).select({
|
||
companyId: loaded.issue.companyId, agentId: managed.agent.id, userId: loaded.interaction.addresseeUserId!,
|
||
adapterType: managed.agent.adapterType, model: managed.adapterConfig.model,
|
||
runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent,
|
||
binding: managed.binding, allowUninstalledPersonal: true,
|
||
});
|
||
usableAiConnection = await access.getConnection(selected.connection.id, loaded.issue.companyId);
|
||
} catch (error) { if (![403, 404, 422].includes((error as { status?: number }).status ?? 0)) throw error; }
|
||
}
|
||
const aiAccounts = managed ? await aiConnectionService(db).list(loaded.issue.companyId, loaded.interaction.addresseeUserId!) : [];
|
||
const selectedAiAccount = managed ? aiAccounts.find((account) =>
|
||
account.provider === managed.binding.provider && (managed.binding.mode === "responsible_user" || account.method === managed.binding.method)
|
||
&& (managed.binding.mode === "responsible_user" ? account.isDefault
|
||
: account.id === managed.binding.connectionId && account.grantId === managed.binding.grantId)
|
||
) : undefined;
|
||
const selectedAiGrant = selectedAiAccount
|
||
? (await access.listConnectionGrants(selectedAiAccount.id, loaded.issue.companyId)).grants.find(grant => grant.id === selectedAiAccount.grantId)
|
||
: undefined;
|
||
const matchingConnections = inventory.connections.filter((connection) =>
|
||
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
|
||
&& connection.status === "active"
|
||
&& connection.enabled
|
||
);
|
||
const existingConnections = (await Promise.all(matchingConnections.map(async (connection) => {
|
||
if (managed) return connection.id === usableAiConnection?.id ? connection : null;
|
||
const { grants } = await access.listConnectionGrants(connection.id, loaded.issue.companyId);
|
||
const eligible = grants.some((grant) =>
|
||
grant.status === "active"
|
||
&& (grant.kind === "organization" || grant.subjectUserId === loaded.interaction.addresseeUserId
|
||
|| (grant.kind === "agent" && grant.subjectAgentId === payload.requestingAgentId))
|
||
);
|
||
return eligible && connection.connectionPurpose !== "ai" ? connection : null;
|
||
}))).filter((connection): connection is ToolConnection => connection !== null);
|
||
return {
|
||
version: 1,
|
||
interaction: loaded.interaction,
|
||
service: {
|
||
service: app.slug,
|
||
name: app.name,
|
||
description: app.description ?? null,
|
||
logoUrl: app.branding.logoUrl ?? null,
|
||
methods: app.methods,
|
||
source: app.source,
|
||
state: existingConnections.length > 0 ? "needs_user_action" : "available",
|
||
connectionId: null,
|
||
},
|
||
existingConnections: existingConnections.map(({ id, applicationId, name, status, enabled }) => ({
|
||
id, applicationId, name, status, enabled,
|
||
})),
|
||
requestedAgentId: payload.requestingAgentId,
|
||
canGrantAccess: payload.accessRequest ? options.canManageOrganizationGrant === true : undefined,
|
||
aiConnection: managed?.binding,
|
||
aiConnectionRequiresAdoption: managed?.requiresAdoption || undefined,
|
||
aiRepair: selectedAiAccount ? {
|
||
connection: selectedAiAccount,
|
||
canReconnect: selectedAiGrant?.createdByUserId === loaded.interaction.addresseeUserId
|
||
&& (selectedAiAccount.ownership === "personal"
|
||
? selectedAiAccount.ownerUserId === loaded.interaction.addresseeUserId
|
||
: options.canManageOrganizationGrant === true),
|
||
} : undefined,
|
||
};
|
||
}
|
||
|
||
async function complete(
|
||
interactionId: string,
|
||
connectionId: string,
|
||
userId: string,
|
||
options: {
|
||
canManageOrganizationGrant?: boolean;
|
||
bypassCurrentMembershipCheck?: boolean;
|
||
validatedAdoption?: { agentUpdatedAt: Date; binding: AiConnectionBinding };
|
||
} = {},
|
||
) {
|
||
const loaded = await loadIntent(interactionId);
|
||
if (loaded.interaction.status !== "pending") {
|
||
if (loaded.interaction.status === "accepted" && loaded.interaction.result?.connectionId === connectionId && loaded.interaction.addresseeUserId === userId) return loaded.interaction;
|
||
throw conflict("Connection intent is already resolved");
|
||
}
|
||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can connect this service");
|
||
await assertCurrentUserWriteAccess(
|
||
loaded.issue.companyId,
|
||
userId,
|
||
options.bypassCurrentMembershipCheck,
|
||
);
|
||
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
|
||
return db.transaction(async (tx) => {
|
||
const [task] = await tx.select().from(issues).where(and(eq(issues.id, loaded.issue.id), eq(issues.companyId, loaded.issue.companyId))).for("update");
|
||
if (!task || task.assigneeAgentId !== payload.requestingAgentId || ["done", "cancelled"].includes(task.status)) throw conflict("Connection request no longer belongs to an active task");
|
||
// Membership downgrade/removal takes the same row lock. Whichever side
|
||
// commits first is authoritative: a completed revocation makes this
|
||
// revalidation fail, while completion holds authority through OAuth
|
||
// finalization, every install/delegation, and intent resolution.
|
||
await lockCurrentUserWriteAccess(
|
||
tx,
|
||
loaded.issue.companyId,
|
||
userId,
|
||
options.bypassCurrentMembershipCheck,
|
||
);
|
||
const txDb = tx as unknown as Db;
|
||
const txAccess = toolAccessService(txDb);
|
||
const txInteractions = issueThreadInteractionService(txDb);
|
||
const current = await txInteractions.getForIssue(task, interactionId) as ConnectionIntentInteraction;
|
||
if (current.status !== "pending") {
|
||
if (current.status === "accepted" && current.result?.connectionId === connectionId) return current;
|
||
throw conflict("Connection intent is already resolved");
|
||
}
|
||
if (payload.accessRequest && (payload.accessRequest.connectionId !== connectionId || !options.canManageOrganizationGrant)) {
|
||
if (payload.accessRequest.connectionId !== connectionId) throw conflict("Use the connection named in this access request");
|
||
throw forbidden("Granting agent tool access requires connection-management authority");
|
||
}
|
||
await lockToolConnectionRuntimeInputs(tx, loaded.issue.companyId, connectionId);
|
||
let selectedConnection = await txAccess.getConnection(connectionId, loaded.issue.companyId);
|
||
const selectedApplication = await txAccess.getApplication(
|
||
selectedConnection.applicationId,
|
||
loaded.issue.companyId,
|
||
);
|
||
if (sourceSlugForConnection(
|
||
selectedConnection,
|
||
new Map([[selectedApplication.id, selectedApplication]]),
|
||
) !== payload.serviceSlug) {
|
||
throw notFound("Connection does not match this intent");
|
||
}
|
||
if (selectedConnection.status !== "active" || !selectedConnection.enabled || isToolConnectionAttentionHealth(selectedConnection.healthStatus)) {
|
||
throw conflict("Finish and test this connection before using it for the task");
|
||
}
|
||
|
||
if (payload.purpose === "channel") {
|
||
if (payload.serviceSlug !== "agentmail" || !options.canManageOrganizationGrant) {
|
||
throw forbidden("AgentMail inbox setup requires connection-management authority");
|
||
}
|
||
const inboxes = await assignedAgentmailInboxes(txDb, loaded.issue.companyId, payload.requestingAgentId);
|
||
const inbox = inboxes.find(inbox => inbox.connectionId === selectedConnection.id);
|
||
if (!inbox?.address) throw conflict("Finish creating an active inbox for the requesting agent before continuing");
|
||
const credentialId = selectedConnection.config?.credentialConnectionId;
|
||
if (typeof credentialId !== "string") throw conflict("Use a saved AgentMail account for this inbox");
|
||
await emailConnectionService(txDb).get(loaded.issue.companyId, credentialId, { userId });
|
||
return txInteractions.resolveConnectionIntent(loaded.issue, interactionId, {
|
||
version: 1, outcome: "connected", connectionId: selectedConnection.id,
|
||
instruction: "Your AgentMail inbox is ready. Use agentmail_inboxes to read its verified address and continue the user's request.",
|
||
}, { userId });
|
||
}
|
||
if (selectedConnection.connectionPurpose === "channel") throw conflict("An email inbox cannot satisfy a tool connection request");
|
||
if (payload.purpose === "ai" && selectedConnection.connectionPurpose !== "ai") throw conflict("Select an AI account for this authentication request");
|
||
if (selectedConnection.connectionPurpose === "ai") {
|
||
if (payload.purpose !== "ai") throw conflict("AI authentication cannot satisfy a tool connection request");
|
||
let managed;
|
||
if (options.validatedAdoption) {
|
||
await tx.select({ id: agents.id }).from(agents)
|
||
.where(and(eq(agents.companyId, loaded.issue.companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`))
|
||
.orderBy(agents.id).for("no key update");
|
||
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId))).for("no key update");
|
||
if (!agent || agent.runtimeConfig.aiConnection || agent.updatedAt.getTime() !== options.validatedAdoption.agentUpdatedAt.getTime()) {
|
||
throw conflict("The agent changed during validation. Reload the task and try again.");
|
||
}
|
||
const binding = options.validatedAdoption.binding;
|
||
if (binding.provider !== payload.serviceSlug || binding.mode !== "responsible_user") throw conflict("Invalid legacy adoption binding");
|
||
const updated = await updateAgentConfigurationInTransaction(txDb, agent.id, {
|
||
runtimeConfig: { ...agent.runtimeConfig, aiConnection: binding },
|
||
}, { recordRevision: { createdByUserId: userId, source: "patch" } });
|
||
if (!updated) throw notFound("Agent not found");
|
||
await logActivity(txDb, {
|
||
companyId: loaded.issue.companyId, actorType: "user", actorId: userId,
|
||
action: "agent.updated", entityType: "agent", entityId: agent.id,
|
||
details: { connectionIntentId: interactionId, aiConnectionAdopted: true },
|
||
});
|
||
managed = { agent: updated, binding, adapterConfig: updated.adapterConfig, requiresAdoption: false };
|
||
} else {
|
||
managed = await managedAgent(loaded.issue.companyId, payload.requestingAgentId, payload.serviceSlug, { sourceRunId: loaded.interaction.sourceRunId }, txDb);
|
||
}
|
||
if (!managed) throw conflict("Configure the agent’s AI connection before using this account");
|
||
const service = aiConnectionService(txDb);
|
||
if (managed.binding.mode === "responsible_user") {
|
||
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.adapterConfig.model, runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent, binding: managed.binding, allowUninstalledPersonal: true });
|
||
if (selected.connection.id !== selectedConnection.id) throw conflict("Choose this account as your personal default in Connections first");
|
||
const installs = await txAccess.listConnectionInstalls(selectedConnection.id, loaded.issue.companyId);
|
||
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: [...installs, { targetType: "agent", targetId: payload.requestingAgentId }] }, { actorType: "user", actorId: userId });
|
||
}
|
||
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.adapterConfig.model, runnerProvider: managed.adapterConfig.provider, acpxAgent: managed.adapterConfig.acpxAgent, binding: managed.binding });
|
||
if (selected.connection.id !== selectedConnection.id) throw conflict("This is not the account selected for the agent");
|
||
return txInteractions.resolveConnectionIntent(loaded.issue, interactionId, { version: 1, outcome: "connected", connectionId: selected.connection.id }, { userId });
|
||
}
|
||
|
||
let { grants } = await txAccess.listConnectionGrants(
|
||
selectedConnection.id,
|
||
loaded.issue.companyId,
|
||
);
|
||
const pendingPersonalGrant = grants.find((grant) =>
|
||
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
|
||
);
|
||
if (!payload.accessRequest && selectedConnection.authKind === "oauth" && pendingPersonalGrant) {
|
||
// txAccess is bound to the outer transaction. Its internal transactions
|
||
// become savepoints, so activation, credential bindings, and the
|
||
// requesting agent's access roll back with any later failure.
|
||
await txAccess.finalizeOAuthAccess(
|
||
loaded.issue.companyId,
|
||
selectedConnection.id,
|
||
{ grantKind: "user" },
|
||
{ actorType: "user", actorId: userId },
|
||
payload.requestingAgentId,
|
||
);
|
||
selectedConnection = await txAccess.getConnection(
|
||
selectedConnection.id,
|
||
loaded.issue.companyId,
|
||
);
|
||
({ grants } = await txAccess.listConnectionGrants(
|
||
selectedConnection.id,
|
||
loaded.issue.companyId,
|
||
));
|
||
}
|
||
const personalGrant = grants.find((grant) =>
|
||
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
|
||
);
|
||
const organizationGrant = grants.find((grant) =>
|
||
grant.kind === "organization" && grant.status === "active"
|
||
);
|
||
const dedicatedGrant = grants.find((grant) => grant.kind === "agent" && grant.status === "active" && grant.subjectAgentId === payload.requestingAgentId);
|
||
if (!personalGrant && !organizationGrant && !dedicatedGrant) {
|
||
throw conflict("This connection has no usable identity grant");
|
||
}
|
||
if (!personalGrant && !dedicatedGrant && !options.canManageOrganizationGrant) {
|
||
throw forbidden("Sharing a company connection requires connection-management authority");
|
||
}
|
||
|
||
if (personalGrant) {
|
||
await txAccess.createConnectionGrantDelegation(
|
||
selectedConnection.id,
|
||
personalGrant.id,
|
||
payload.requestingAgentId,
|
||
userId,
|
||
);
|
||
}
|
||
|
||
const installs = await txAccess.listConnectionInstalls(
|
||
selectedConnection.id,
|
||
loaded.issue.companyId,
|
||
);
|
||
const requestedInstall = { targetType: "agent" as const, targetId: payload.requestingAgentId };
|
||
const additiveInstalls = installs.some((install) =>
|
||
install.targetType === requestedInstall.targetType && install.targetId === requestedInstall.targetId
|
||
) ? installs : [...installs, requestedInstall];
|
||
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: additiveInstalls }, {
|
||
actorType: "user",
|
||
actorId: userId,
|
||
});
|
||
|
||
if (payload.accessRequest) {
|
||
await grantConnectionAgentTools(txDb, {
|
||
connection: selectedConnection, agentId: payload.requestingAgentId, userId,
|
||
tools: payload.accessRequest.tools, interactionId,
|
||
context: { issueId: task.id, projectId: task.projectId },
|
||
});
|
||
}
|
||
|
||
const effective = await txAccess.getEffectiveProfilesForAgent(loaded.issue.companyId, payload.requestingAgentId);
|
||
if (!effective.allowedTools.some((tool) => tool.connectionId === selectedConnection.id)) throw conflict("This connection has no permitted tools. Review its action permissions before continuing.");
|
||
|
||
const runtimeConnection = await connectionIntentService(txDb).usableConnectionForAgent({
|
||
companyId: loaded.issue.companyId, agentId: payload.requestingAgentId,
|
||
responsibleUserId: userId, serviceSlug: payload.serviceSlug,
|
||
});
|
||
if (runtimeConnection?.id !== selectedConnection.id) throw conflict("This identity is not the connection this agent can execute. Resolve conflicting identities before continuing.");
|
||
|
||
return txInteractions.resolveConnectionIntent(
|
||
loaded.issue,
|
||
interactionId,
|
||
{ version: 1, outcome: "connected", connectionId: selectedConnection.id,
|
||
...(isRemoteMcpConnectorId(payload.serviceSlug) ? { instruction: aggregatorContinuationInstruction(payload.serviceSlug, payload.upstreamService?.name ?? "The requested app") } : {}),
|
||
},
|
||
{ userId },
|
||
);
|
||
});
|
||
}
|
||
|
||
async function decline(
|
||
interactionId: string,
|
||
userId: string,
|
||
reason?: string,
|
||
options: { bypassCurrentMembershipCheck?: boolean } = {},
|
||
) {
|
||
const loaded = await loadIntent(interactionId);
|
||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can decline this request");
|
||
await assertCurrentUserWriteAccess(
|
||
loaded.issue.companyId,
|
||
userId,
|
||
options.bypassCurrentMembershipCheck,
|
||
);
|
||
return interactions.resolveConnectionIntent(
|
||
loaded.issue,
|
||
interactionId,
|
||
{ version: 1, outcome: "declined", reason: reason?.trim() || null },
|
||
{ userId },
|
||
);
|
||
}
|
||
|
||
return {
|
||
validate: loadRunContext,
|
||
usableConnectionForAgent,
|
||
search,
|
||
request,
|
||
// Controller-only entry point. Runtime tokens still require a running run.
|
||
requestForRunAuthFailure: async (runId: string) => {
|
||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||
if (!run || run.status !== "failed" || !isAiAuthenticationRepairable(run) || !run.responsibleUserId) return null;
|
||
const context = await loadRunContext({ sub: run.agentId, company_id: run.companyId, run_id: run.id, responsible_user_id: run.responsibleUserId }, true);
|
||
const [latest] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||
eq(heartbeatRuns.companyId, run.companyId),
|
||
sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId', ${heartbeatRuns.nativeIssueId}::text) = ${context.issue.id}`,
|
||
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1);
|
||
if (latest?.id !== run.id) return null;
|
||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)));
|
||
if (!agent) return null;
|
||
const saved = aiConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
const selected = router ? record(run.contextSnapshot?.aiRouterSelection) : null;
|
||
const managed = router && selected ? await managedAgent(run.companyId, run.agentId, String(record(selected.binding)?.provider), { sourceRunId: run.id }) : null;
|
||
const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, run);
|
||
if (!binding) return null;
|
||
const attribution = record(run.contextSnapshot?.aiConnection);
|
||
if (attribution && attribution.provider !== binding.provider) return null;
|
||
if ((saved || managed) && attribution && typeof attribution.identity === "string" && typeof attribution.connectionId === "string" && typeof attribution.grantId === "string") {
|
||
await aiConnectionService(db).markAuthenticationFailed({ companyId: run.companyId, runId: run.id, agentId: run.agentId,
|
||
runStartedAt: run.startedAt ?? run.createdAt,
|
||
attribution: attribution as unknown as AiConnectionAttribution & { identity: string } });
|
||
}
|
||
return requestWithContext(context, binding.provider, { purpose: "ai" });
|
||
},
|
||
loadIntent,
|
||
setupOptions,
|
||
complete,
|
||
decline,
|
||
updatePhase: async (
|
||
interactionId: string,
|
||
phase: "requested" | "authorizing" | "needs_retry",
|
||
userId: string,
|
||
options: { bypassCurrentMembershipCheck?: boolean } = {},
|
||
) => {
|
||
const loaded = await loadIntent(interactionId);
|
||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can update this request");
|
||
await assertCurrentUserWriteAccess(
|
||
loaded.issue.companyId,
|
||
userId,
|
||
options.bypassCurrentMembershipCheck,
|
||
);
|
||
return interactions.updateConnectionIntentPhase(loaded.issue, interactionId, phase, { userId });
|
||
},
|
||
};
|
||
}
|