mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## Thinking Path > - Paperclip manages agent work and the tools each agent can use. > - GitHub bots need their own App identity and restricted repository access. > - Manual App configuration and repeated setup gates make that connection hard to finish. > - GitHub can create an App from a manifest, but people must approve creation and repository access. > - Each Paperclip stack should own configuration, credentials, signature verification, and task admission. > - This change uses Cloud as an encrypted callback and event gateway, then completes setup in the stack. > - GitHub review bots have their own experimental setting. Chat connector opt-in does not control them. ## Linked Issues or Issue Description **What existing behavior does this improve?** Dedicated GitHub App setup, bot settings, and task-scoped GitHub replies. **Current behavior** Users must configure an App, copy credentials, repeat repository selection, and complete manual verification gates. Review settings are hard to scan. Mentions in descriptions can be missed, and automatic completion text can produce an extra GitHub comment. **Proposed behavior** Choose an agent and connect GitHub under a personal account or organization. GitHub asks for creation and installation consent. Paperclip resumes the same draft, stores secrets locally, imports access, verifies delivery, and opens Settings. Each accepted request has one editable App-owned comment for progress and results. Review assessments produce deterministic checks for the reviewed commit. **Breaking changes** GitHub bots require their own default-off experimental opt-in. The setup-method and delegated-agent screens are removed. Per-person automatic-run flags remain readable but are replaced by author filters. Existing connections, direct webhooks, saved restrictions, and manual existing-App recovery remain supported. Companion gateway: https://github.com/paperclipai/paperclip-cloud/pull/688 (merged). The roadmap and related PRs were checked; no duplicate dedicated-App wizard was found. ## What Changed - Add an independent **GitHub review bots** setting across the catalog, routes, identity admission, and task controls. Existing bots continue running when the UI setting is off. - Replace setup with Choose agent and Connect GitHub. Support personal and organization owners, inline low-trust conversion, saved drafts, known organization suggestions, automatic completion, and same-App recovery. - Keep manifests, OAuth PKCE, credential exchange, vault storage, installation verification, and original webhook-signature validation in the stack. Use the Cloud sealed gateway for public callbacks and outbound local delivery. - Add resumable owner metadata in migration 0323. Retry a failed registration renewal through the same durable replacement rather than creating another App route. - Support authorized mentions in issue and PR bodies, comments, inline comments, and review summaries. Preserve attachments, edited-source identity, duplicate protection, and lifecycle invalidation. Filter requests explicitly addressed to another connected bot. - Give agents task-scoped review, comment, and update tools. Update one working comment through progress and final output. Close coalesced requests and recover delayed acknowledgements, including deleted earlier working comments. Preserve automatic replies for legacy connections without saved review configuration. Preserve partial publication evidence and quarantine uncertain automatic fallbacks without blocking Board messages or questions. - Reorganize Settings and Access with rich slash-skill instructions, numeric passing scores, clear automation controls, formal-review tooltips, member and contributor sections, and verified App branding links. Preserve invalid score drafts and automation choices across tabs. - Paginate repositories in 20-row batches with search and a true connection-wide toggle. Batch verification at GitHub's 500-ID token limit. Keep saved repository overrides while hiding their editor. - Give each review a detail URL that remains usable beyond the newest 100 records. Synchronize the manager OpenAPI contracts and add recovery, authorization, UI, and browser regressions. - Reconcile the current master branch and retain its agent avatar, feature catalog, and extracted heartbeat preparation code. Preserve a saved test-drive port and recover from an occupied high port. ## Verification - Focused UI, independent experimental-setting, and CLI tests: 199 passed. - `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` passed. The broad local Vitest attempt was interrupted after resource timeouts and stale expectations; the affected regressions passed focused reruns. The latest-head full CI matrix passed, including all browser shards, server/chat suites, builds, typechecking, canary packaging, and Docker context integrity. A Docker Hub 429 required a CI retry; it passed without changing the code. - Final review repairs: 127 attachment/event tests and server typecheck passed. All 13 final integration regressions passed under the normal CI timeout, covering legacy reply compatibility, deleted coalesced comments, inline-only review completion, progress edits, and native final recovery. Restart-safe description/review attachments passed the attachment suite. - Final regressions: four reply/lifecycle tests, both native GitHub wait tests, 171 feature-gate/reply-contract tests, the routed review preview, and five scheduler/reply regressions passed. The final fixture repair also passed all six setup-continuation cases and all three complete chat-test shards in CI, covering terminal receipts and retries under the normal CI timeout. One loaded-host receipt run exceeded its local timeout; the exact case passed in an isolated rerun and the complete CI shard. - GitHub integration tests cover personal and organization setup, retry recovery, edited mentions, attachment provenance, coalesced requests, delayed acknowledgements, partial review writes, old review URLs, and company boundaries. - Browser qualification: both GitHub journeys, all five provider setup/management journeys, and the GitHub uncertain-send recovery journey passed. GitHub setup, reconnect, and task replies run with chat disabled; chat enabled alone cannot expose GitHub bots. The disposable qualification database was removed after testing. - Earlier live qualification verified App-owned issue replies, native slash-skill invocation, progress comment edits, and a 5/5 PR review with a passing current-head check in the dedicated permissions-smoke repository. These live checks did not use personal credentials as bot execution authority. ## Risks - Deploy the merged Cloud gateway protocol before the stack update. Each callback and encrypted event must retain the original instance and tenant binding. - Migration 0323 uses idempotent column additions for test installations that already applied the earlier draft migration number. - A verified connection does not prove runtime isolation or a completed review. Runtime checks remain separate. - Required merge checks must be configured in GitHub. A passing numeric score does not formally approve a PR; approval and request-changes tools remain opt-in. - Existing-App credentials cannot silently renew an uncertain manifest exchange. Recovery preserves the existing App, draft, connection, and agent. - Repository changes save immediately. Global toggles affect all currently available repositories, including unloaded rows, without granting new installation access. - A failed or uncertain tool write suppresses automatic fallback text; the governed outbox retains its evidence. Deleted or replaced response comments are not recreated through a personal credential fallback. - GitHub logo upload remains an optional human action in GitHub App settings. Runtime provisioning, delegated setup, screenshot publication, and production push delivery remain deferred. ## Model Used OpenAI Codex, based on GPT-6, with tool use and code execution. The exact runtime model identifier and context size 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 references) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused suites; complete test matrix is checked in CI) - [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>
1231 lines
80 KiB
TypeScript
1231 lines
80 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 { 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 tx.select({ id: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, loaded.issue.companyId))).for("update");
|
||
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) {
|
||
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId))).for("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 });
|
||
},
|
||
};
|
||
}
|