Files
PaperClipAI/server/src/services/connection-intents.ts
T
DottaandPaperclip 0e0b63e5a5 feat(connections): add experimental task-pinned AI routing (#14967)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - AI Connections separate account access from models and harnesses.
> - A pool must act as one connection while retaining each task’s
account.
> - Core must enforce member access and preserve session and recovery
rules.
> - A plugin supplies rotation policy without receiving credentials.
> - This change adds durable routing and native connector setup and
management.

## Linked Issues or Issue Description

**Subsystem affected**

AI Connections, Connectors, plugins, run dispatch, and session
compatibility.

**Problem or motivation**

Operators need to rotate new tasks across saved accounts while each task
keeps its account and session. Pool setup must fit the existing
connector catalog and account workflow.

**Proposed solution**

Add an experimental router binding, a capability-gated plugin hook, and
transactional task pins. Plugins declare native pooled connectors
through `aiConnectionRouter`. Core hosts the existing-account picker,
ordering step, and account settings. Related usage contract: #14936.
Companion private plugin:
https://github.com/paperclipai/paperclip-cloud/pull/643.

**Roadmap alignment**

This extends Apps and AI Connections. Core supplies generic enforcement
and native connector UI; the private plugin owns rotation and quota
policy. The prior duplicate search found no matching router
implementation.

## What Changed

- Add a router binding without changing existing concrete bindings. Keep
the instance flag and new pools disabled by default. Require manual
operator configuration. Show no routing toggle in Experimental settings
on either open-source or Cloud installs, even after routing is enabled.
- Persist company-scoped pools, one shared cursor per pool, and pins
keyed by company, pool, agent, and task. Commit pins and cursor advances
together with revision checks and bounded retries. Persist run-ID
affinity before allocation.
- Pass only authorized metadata and normalized usage to plugins. Core
retains credential handling, member access checks, runtime
qualification, and recovery evidence. Probe outside locks with a shared
15-second budget and freshness cache.
- Resolve routing before credential preparation and backend selection.
Preserve pins through turns, session resets, removed members, and quota
waits. Retain admitted recovery after disable or uninstall.
- Separate credential session epochs from token generations. Verified
refresh preserves the epoch; reconnect and manual replacement change it.
Include the credential slot ID in session and usage-cache identity, so
reconnecting an indexed legacy account invalidates its old session even
when both epochs are zero.
- Validate pool member installations before accepting saved-agent
bindings and recheck compatibility when the harness changes. Install
only authorized members in the new-agent transaction and record their
IDs in local activity. Pool membership cannot install a restricted
shared connection.
- Preserve pool bindings when agents hire teammates through either
creation API or native caller runtime inheritance. Block stale manager
credential references; retain explicit child authentication precedence
and reject incompatible inherited pools.
- Add native connector registration through plugin metadata. Reuse the
Connectors catalog, setup header, account header, sidebar, dialogs, and
usage display. Setup selects and orders saved connections. Advanced
settings hold usage rules and member runtime defaults. New-account setup
opens in another tab.
- Use revision-checked pool archival from the Connectors catalog and
account page. Keep task pins, cursors, recovery evidence, and underlying
connections. Reject ordinary connection updates or removals that bypass
pool revisions.
- Add pool selectors, composer models, override notes, quota status, run
details, activity records, and local run-log records. Keep
session-adoption copy minimal.
- Show **Used by** below the pool connections. List current company
agents with shared avatars and profile links. Include paused agents;
exclude terminated agents and agents using another pool.
- Add Core stories for the generic connector workflow and runtime
surfaces. Cloud stories reuse these production routes and tokens through
a preview-only alias.

## Verification

- Final head `73cb953bca30ed83e4505dd820edd9b5edffd28b`: full workspace
`pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass
locally.
- All 422 focused connector/settings/shared-contract/migration tests and
all 156 database-backed AI connection, hiring, reconnect, and
durable-routing cases pass (69 hiring cases rerun after the final
auth-precedence fix). The merged shared contract retains connection
instructions and pool metadata. The pool migration is generated at
sequence 0299 after the latest upstream migrations; this PR makes no
lockfile changes.
- All four full-app Playwright tests pass on the final head after a cold
restart and migration, against the installed private plugin and isolated
database, with no route or pool-API mocks. They cover hidden routing
controls after manual opt-in, native pool creation, ordering, membership
edits, rename, paused defaults, enabling/save/refresh persistence, stale
edits, cancellation/removal, preserved underlying accounts, unavailable
routers, and Used by avatars and profile links. Exact command:
`PAPERCLIP_CONNECTION_POOL_E2E=1
AI_CONNECTIONS_TEST_COMPANY_ID=a37b9625-5ecf-4e29-8081-04df3d6e7d6f
AI_CONNECTIONS_TEST_URL=http://127.0.0.1:3108 pnpm exec playwright test
--config tests/ai-connections-app/playwright.config.ts
connection-pools.spec.ts`.
- [Native setup, ordering, and management
screenshots](https://github.com/paperclipai/paperclip/pull/14967#issuecomment-6006976278)
address the review follow-up. [Earlier selector, quota, and run-detail
screenshots](https://github.com/paperclipai/paperclip/pull/14967#issuecomment-5971537316)
show the runtime surfaces. Core previews: `pnpm --filter @paperclipai/ui
storybook`, then **Connectors / Pool host** or **AI Connections /
Connection pools**. Cloud owns its host-backed plugin stories; both
repositories’ Operator Setup Required story assertions pass.
- Live acceptance used OpenAI/Codex and Anthropic/Claude ACPX, resumed
both exact sessions after restart, preserved pinned accounts through
explicit reset and controlled quota deferral/recovery, and committed
only two allocations across fourteen runs. A later UI-created task test
again rotated OpenAI then Anthropic and resumed OpenAI through
follow-up/restart/quota recovery. That later Anthropic execution was
blocked by its saved OAuth token expiring (provider 401). No live usage
probes ran.
- The full local `pnpm test:run` was attempted earlier and did not
complete because of macOS embedded PostgreSQL bootstrap/shared-memory
failures and the 40,000-file Git fixture timeout. The focused database
suites above now pass; full-suite verification is provided by the split
CI lanes. The preceding CI run had one runtime readiness timeout; it
passes locally both alone and inside the larger runtime suite. That
larger local suite also encountered an embedded PostgreSQL setup failure
and two macOS temporary-path alias assertions; those two assertions pass
with canonical TMPDIR=/private/tmp. All final-head CI checks are
terminal green, including full general/serialized server suites, Runner
checks, browser E2E shards, canary verification, build, and typecheck.
Greptile is 5/5 on that exact head with no unresolved threads.

## Risks

- The migration adds routing tables and a credential epoch column.
Install the private plugin only with the compatible Core contract.
- Routing and each pool require opt-in. Production distribution and
fleet defaults remain unchanged.
- Unknown usage stays eligible. Known pinned exhaustion waits; revoked
access requires operator repair.
- Legacy adapters require compatible members. Runner model and effort
overrides remain limited by qualified backend support.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository editing, code
execution, and browser 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
- [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 (targeted suites;
full-suite limitations are reported above)
- [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>
2026-10-05 20:44:37 -05:00

1213 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } 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,
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,
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" || !isAiAuthenticationFailure(run.errorCode) : 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" || 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 publicService = findAggregatorService(serviceQuery, publicMatches) ?? (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);
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." };
return { version: 1, query, results: offered, providerQuestion: aggregatorProviderQuestion(targetService, targetName, offered),
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, questions:[providerQuestion]}; do not add questionSet. Otherwise use ask_user_questions with the same questions payload. 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: "Multiple apps match this query. 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)) throw unprocessable("A requested tool is not in this connection's active catalog");
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" && isAiAuthenticationFailure(source.errorCode) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) {
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig) });
}
}
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 agentService(txDb).update(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" || !isAiAuthenticationFailure(run.errorCode) || !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);
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 });
},
};
}