mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
## 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>
1213 lines
79 KiB
TypeScript
1213 lines
79 KiB
TypeScript
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 });
|
||
},
|
||
};
|
||
}
|