mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip manages AI agents doing work for people. > - Agent verification must finish before an accepted task can start. > - A missing AI account default can prevent verification before a provider starts. > - Lifecycle policy treated that owned configuration refusal as a temporary failure and retried each minute. > - The accepted turn remained Queued without explaining the prerequisite. > - Park account configuration failures and reuse the task's existing inline connection card. > - Successful repair rechecks verification and releases the original queued turn under its original authority. ## Linked Issues or Issue Description **What happened?** An OpenCode agent selected the responsible user's OpenRouter default, but that default did not exist. Verification retried unchanged configuration indefinitely and its accepted task stayed Queued. **Expected behavior:** Preserve accepted work, explain the setup prerequisite, and let the appropriate person repair it using the existing task connection UI. Completion must verify the agent before starting the same queued turn. Genuine pause, approval and access gates remain effective. **Reproduction:** Create an OpenCode Runner agent with a responsible-user OpenRouter binding and no personal default; assign a task while verification is pending. The base retries verification and shows an unexplained queue. Related: #18702 handles pending task connection recovery after account repair. This PR handles the earlier agent-verification boundary. #20330 addresses a separate Codex governed-wait timeout and is already merged. ## What Changed - Share owned account-selection failure evidence through a pure library contract. Lifecycle policy parks those configuration failures with fixed, redacted guidance; temporary infrastructure errors retain their retries. - Batch setup-state reads for unstarted queued turns. Distinguish manual repair from automatic setup retries and preserve Stop, started/history state and lifecycle holds. - Reuse the existing `connection_intent` card, account selection and completion APIs. Its controller-only `agentSetup` marker accepts only never-started legacy turns; runtime tokens still require a running run. - Address the card to the lifecycle verifier's account owner, preserving the task's execution principal and private-task visibility. Attach missing cards on admission, verification, startup and full recovery, including retained retries and continuations. Repeated delivery and publication remain bounded. A new queued turn supersedes the setup card of a stopped turn; generic connection cards retain cross-run reuse. - Recheck lifecycle verification after usable-account completion, then release the existing turn through the normal readiness path. Preserve decline, Stop, reassignment and changed setup-owner gates. Fence an in-flight verification failure from overwriting a repair; add no provider wake or recurring worker. - Park owned account refusals during resume as well as initial verification. Bind setup cards to the lifecycle operation, settle obsolete deliveries, and keep explicit Retry guidance accurate. - Reuse the router’s existing lifecycle pin for setup repair without rerouting the task, advancing the pool cursor, or replacing its saved binding. Legacy direct-account cards remain readable. - Hide the incorrect Resume banner during setup, retaining genuine manual/budget pause controls. Document the execution and retry contract. ## Verification - Red: eight original behavior assertions failed on the base, including the real selector's retry decision and the real batched queue projection. Six owned selection cases failed classification. Temporary-error presentation, different task/verifier users, retained retry/continuation recovery and the setup Resume banner also have failing-before/passing-after regressions. - Green: 430 affected backend, lifecycle, shared-contract and UI tests passed before the final review corrections. Subsequent focused runs cover one inline card, real account selection/completion, duplicate delivery/publication, verification before readiness, unchanged run authority, private tasks, no extra provider wake, decline/Stop/reassignment, company isolation, invalid runtime tokens, transient retries and redacted diagnostics. - Final setup/card and generic connection-service suites: 49 tests passed. The stopped-turn case failed before the correction, then passed with real account completion, the stopped source remaining cancelled, and the replacement source remaining queued. - After rebase, all 129 focused assertions passed. The local queue suite’s 88 execution assertions passed repeatedly, but its database-fixture teardown exceeded both 30-second and 120-second hooks. An isolated queue case completed, and the setup/connection suites fully passed. The original queue test and cleanup remain unchanged; broad CI supplies the full-suite result. - Server/UI typechecks, module boundaries, token gates and diff checks passed. `pnpm verify --explain` requested broad CI because the execution-contract document changed; no redundant full local suite was substituted. - Real source acceptance: macOS arm64, Node 26.4.0, isolated embedded PostgreSQL and local board identity. Created an OpenCode/OpenRouter agent and task through the API with no default. Admission retained one unstarted turn and produced the existing inline Connect OpenRouter card. The browser opened its credential form and closed it without entering credentials. The same card/run persisted after server restart and reload, with the setup label and no incorrect Resume. The browser also reproduced Stop followed by a new queued turn retaining the stale card on the prior head. On `12f609c98372fd1e50644028db75cfcc8deb210d`, startup superseded the stale card and retained the same replacement turn, with the original turn still cancelled and no provider execution. - The earlier CI case-search failure also reproduces on exact base `0e3fdebea8257bdff11573081c76b306f12db230` without this PR. Its repair is now in master via #20371; this branch is rebased onto it and contains no API-search changes. - Staging's actual Runtime test returned “Connect an account and choose your personal default” before provider startup. The original turn later started independently on deployed code, then hit a separate workspace-export failure. Automatic file recovery subsequently marked that run successful; its task remains Blocked and the trial’s useful output is unverified. This PR is not deployed; provider authentication through its card and downstream publication remain unverified. No account, credential, default, permission or binding was changed during diagnosis. - Previous revision review: Greptile 5/5 on `12f609c98372fd1e50644028db75cfcc8deb210d`, with all four inline findings addressed and resolved. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42713494508) on that revision: 59 successful checks, one neutral check and three intentional skips, with no pending or failed checks. - Latest revision `976bcae993805a8a2222395bbff9394f6791b424`: resume, router and Retry-copy regressions failed before repair. Green: 92 lifecycle/setup/shared tests, 83 setup/generic-connection/router tests, and 137 UI/shared tests. Server/UI typechecks, module boundaries and token gates passed. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42720731137) on this exact revision: 59 successful checks and three intentional skips, with no pending or failed checks. Greptile reviewed this head at 5/5; all six review findings are addressed and resolved. Real Stop/retry acceptance retained the same replacement turn and current-operation card through two actual server restarts, with no provider execution. ## Risks - Known account configuration failures now require repair or explicit Retry. Temporary failures keep automatic retries; accepted work and authority remain intact. - The projection adds one company-scoped batch read only for unstarted queued turns. Card attachment examines at most 100 active candidates and adds no timer, worker, schema or migration. - The optional stored setup-card marker requires an aware reader during rollback. No run/session/checkpoint identity format changes. Setup presentation grants no access and cannot bypass verification or other gates. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, tool use, code editing and test execution. The exact serving model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1539 lines
106 KiB
TypeScript
1539 lines
106 KiB
TypeScript
import { canActorReadIssuePrivacy } from "./authorization.js";
|
||
import { isAgentAwaitingSetup } from "../modules/agent-lifecycle/index.js";
|
||
import { readAiConnectionConfigurationFailure } from "./ai-connection-configuration-failure.js";
|
||
import { assertAgentConnectionInstallAccess, connectionManagementService } from "./connection-management.js";
|
||
import { CHAT_HUMAN_CREDENTIAL_FIELDS, chatCredentialEntryGenerations } from "./chat-channels/credentials.js";
|
||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||
import { emailChannelService } from "./email-channels.js";
|
||
import { emailConnectionService } from "./email-connections.js";
|
||
import { grantConnectionAgentTools } from "./connection-agent-access.js";
|
||
import { agentService } from "./agents.js";
|
||
import { createHash } from "node:crypto";
|
||
import { isDeepStrictEqual } from "node:util";
|
||
import { logActivity } from "./activity-log.js";
|
||
import { aiConnectionService } from "./ai-connections.js";
|
||
import { aiConnectionBindingSchema, aiConnectionRouterBindingSchema } from "@paperclipai/shared";
|
||
import { aiBindingForAuthRecovery, isAiAuthenticationFailure, isAiAuthenticationRepairable } from "./ai-auth-failure.js";
|
||
import { and, eq, desc, isNull, notInArray, sql } from "drizzle-orm";
|
||
import type { Db } from "@paperclipai/db";
|
||
import {
|
||
agents,
|
||
aiConnectionTaskPins,
|
||
chatEndpoints,
|
||
companies,
|
||
toolConnections,
|
||
connectionGrants,
|
||
toolCatalogEntries,
|
||
toolProfiles,
|
||
toolProfileEntries,
|
||
toolProfileBindings,
|
||
toolPolicies,
|
||
companyMemberships,
|
||
heartbeatRuns,
|
||
issueThreadInteractions,
|
||
issueComments,
|
||
issues,
|
||
} from "@paperclipai/db";
|
||
import {
|
||
APP_STORE_DEFINITIONS,
|
||
isUuidLike,
|
||
isConnectorFeatureEnabled,
|
||
getAppDefinitionForApplicationKey,
|
||
AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES,
|
||
findAggregatorService, searchAggregatorServices, prepareConnectionSearch, scoreConnectionSearch, explicitAggregatorQuery, normalizeConnectionQuery, parseAggregatorRoute, aggregatorProviderQuestion, aggregatorProviderQuestionSet,
|
||
aggregatorContinuationInstruction, isRemoteMcpConnectorId, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema,
|
||
CONNECTABLE_APP_DEFINITIONS,
|
||
connectionIntentPayloadSchema,
|
||
getAvailableConnectionMethods,
|
||
isToolConnectionAttentionHealth,
|
||
type ConnectionSearchResultItem,
|
||
type AggregatorServiceDefinition,
|
||
getAppStoreDefinition,
|
||
type ConnectionIntentInteraction,
|
||
type ConnectionIntentSetupOptions,
|
||
type ConnectionRequestResult,
|
||
type ConnectionsSearchResult,
|
||
type ToolApplication,
|
||
type ToolConnection,
|
||
type AiConnectionAttribution,
|
||
type AiConnectionBinding,
|
||
} from "@paperclipai/shared";
|
||
import { instanceSettingsService } from "./instance-settings.js";
|
||
import { createNativeConnectorYield } from "./connection-native-yield.js";
|
||
import { HttpError, conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
|
||
import { issueThreadInteractionService, type InteractionResolutionMutationOptions } from "./issue-thread-interactions.js";
|
||
import { lockToolConnectionRuntimeInputs, toolAccessService } from "./tool-access.js";
|
||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||
import { captureRunIdentity } from "./run-identity.js";
|
||
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
|
||
import { restConnectorFor } from "./connectors/rest-registry.js";
|
||
|
||
type ConnectionRunClaims = Pick<RuntimeToolsTokenClaims, "sub" | "company_id" | "run_id" | "responsible_user_id">;
|
||
type SavedChatSetup = NonNullable<ConnectionIntentInteraction["payload"]["savedSetup"]>;
|
||
|
||
/** Opaque completion proof committed alongside the rotated vault references. */
|
||
export function chatCredentialRotationId(setup: object): string | null {
|
||
const value: unknown = Reflect.get(setup, "credentialRotationId");
|
||
return typeof value === "string" && isUuidLike(value) ? value : null;
|
||
}
|
||
|
||
export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string; status?: string; botExternalId?: string | null; providerAccountId?: string | null }, action?: SavedChatSetup["action"], previousRotationId?: string | null, credentialRequest?: { fields?: string[]; requireAll?: boolean }) {
|
||
const query = new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id });
|
||
if (action === "enter_credentials" || action === "rotate_credentials") query.set("stage", "credentials");
|
||
const configured = endpoint.status !== "draft" && (
|
||
["active", "paused", "revoked"].includes(endpoint.status ?? "") || !!endpoint.botExternalId || !!endpoint.providerAccountId
|
||
);
|
||
if (action === "enter_credentials" && configured) query.set("reconnect", "1");
|
||
if (action === "confirm_identity") query.set("stage", "identity");
|
||
if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); query.set("afterRotationId", previousRotationId ?? "none"); }
|
||
if (action === "enter_credentials") {
|
||
const fields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => !credentialRequest?.fields || credentialRequest.fields.includes(field));
|
||
query.set("credentialFields", fields.join(","));
|
||
query.set("credentialMode", credentialRequest?.requireAll ? "all" : "any");
|
||
}
|
||
return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`;
|
||
}
|
||
|
||
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",
|
||
);
|
||
}
|
||
|
||
// Large enough for first-party catalogs such as GitHub's; aggregator catalogs
|
||
// stay bounded and still report their total.
|
||
const MAX_SCOPED_REQUEST_TOOL_NAMES = 100;
|
||
|
||
/** Tells an agent outside a connection's grant how to request scoped access to it. */
|
||
function scopedToolRequestGuidance(connectionId: string, catalog: ReadonlyArray<{ toolName: string }>) {
|
||
const names = [...new Set(catalog.map(tool => tool.toolName))].sort();
|
||
const listed = names.slice(0, MAX_SCOPED_REQUEST_TOOL_NAMES);
|
||
return `Call connection_request again with connectionId ${JSON.stringify(connectionId)} and toolNames set to only the exact names this task needs. Available indexed tool names (first ${listed.length} of ${names.length}): ${JSON.stringify(listed)}. No access was granted.`;
|
||
}
|
||
|
||
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, mode: "active" | "failed_auth" | "queued_setup" = "active") {
|
||
let run = await db
|
||
.select({
|
||
id: heartbeatRuns.id,
|
||
companyId: heartbeatRuns.companyId,
|
||
agentId: heartbeatRuns.agentId,
|
||
status: heartbeatRuns.status,
|
||
startedAt: heartbeatRuns.startedAt,
|
||
runtimeMode: heartbeatRuns.runtimeMode,
|
||
errorCode: heartbeatRuns.errorCode,
|
||
resultJson: heartbeatRuns.resultJson,
|
||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||
nativeIssueId: heartbeatRuns.nativeIssueId,
|
||
})
|
||
.from(heartbeatRuns)
|
||
.where(eq(heartbeatRuns.id, claims.run_id))
|
||
.then((rows) => rows[0] ?? null);
|
||
if (
|
||
!run
|
||
|| run.companyId !== claims.company_id
|
||
|| run.agentId !== claims.sub
|
||
|| (!run.activeIdentityContextId && run.responsibleUserId !== claims.responsible_user_id)
|
||
) throw forbidden("Runtime tool token does not match its heartbeat run");
|
||
if (mode === "failed_auth" ? run.status !== "failed" || !isAiAuthenticationRepairable(run)
|
||
: mode === "queued_setup" ? run.status !== "queued" || run.startedAt !== null || run.runtimeMode !== "legacy" || run.activeIdentityContextId !== null
|
||
: run.status !== "running") {
|
||
throw forbidden("Runtime tool token is no longer active");
|
||
}
|
||
if (run.activeIdentityContextId && mode === "active") {
|
||
const current = await captureRunIdentity(db, { companyId: run.companyId, agentId: run.agentId, runId: run.id }, { readScope: "identity" });
|
||
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)),
|
||
};
|
||
}
|
||
|
||
// Reuse host verification's durable lifecycle allocation. This read must not
|
||
// allocate a task route, advance a pool cursor, or replace the agent's pool.
|
||
async function queuedSetupManagedAgent(agent: typeof agents.$inferSelect, client = db) {
|
||
const saved = aiConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
if (saved) return { agent, binding: saved, adapterConfig: agent.adapterConfig, requiresAdoption: false };
|
||
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
const operationId = text(agent.lifecycleOperation?.id);
|
||
if (!router || !operationId) return null;
|
||
const [pin] = await client.select().from(aiConnectionTaskPins).where(and(
|
||
eq(aiConnectionTaskPins.companyId, agent.companyId), eq(aiConnectionTaskPins.agentId, agent.id),
|
||
eq(aiConnectionTaskPins.poolId, router.connectionId), eq(aiConnectionTaskPins.taskKey, `lifecycle:${operationId}`),
|
||
));
|
||
const binding = aiConnectionBindingSchema.safeParse(pin?.selection.binding).data;
|
||
if (!pin || !binding || pin.selection.poolId !== router.connectionId || !isDeepStrictEqual(binding, pin.member.binding)) return null;
|
||
return { agent, binding, adapterConfig: { ...agent.adapterConfig, ...pin.selection.runtimeConfig }, requiresAdoption: false };
|
||
}
|
||
|
||
async function managedAgent(companyId: string, agentId: string, serviceSlug: string, options: { fallback?: AiConnectionBinding; sourceRunId?: string | null; agentSetup?: true; agentSetupOperationId?: string } = {}, client = db) {
|
||
const [agent] = await client.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
||
if (!agent) return null;
|
||
if (options.agentSetup) {
|
||
if (options.agentSetupOperationId && options.agentSetupOperationId !== agent.lifecycleOperation?.id) return null;
|
||
const [source] = options.sourceRunId ? await client.select().from(heartbeatRuns).where(and(
|
||
eq(heartbeatRuns.id, options.sourceRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId),
|
||
)) : [];
|
||
if (!source || source.status !== "queued" || source.startedAt || source.runtimeMode !== "legacy" || source.activeIdentityContextId) return null;
|
||
// Routed setup cards must name the lifecycle operation that selected the
|
||
// account. Legacy direct-account cards remain readable across upgrades.
|
||
if (!options.agentSetupOperationId && aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).success) return null;
|
||
const managed = await queuedSetupManagedAgent(agent, client);
|
||
return managed?.binding.provider === serviceSlug ? managed : 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;
|
||
agentSetup?: true;
|
||
agentSetupOperationId?: string;
|
||
inventory?: Awaited<ReturnType<typeof connectionInventory>>;
|
||
}) {
|
||
const managed = input.purpose === "ai" ? await managedAgent(input.companyId, input.agentId, input.serviceSlug, { sourceRunId: input.sourceRunId, agentSetup: input.agentSetup, agentSetupOperationId: input.agentSetupOperationId }) : 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.getConnectionAssignmentsForAgent(input.companyId, input.agentId, matching.map(connection => connection.id));
|
||
const installedIds = new Set(effective.installedConnections.map((connection) => connection.id));
|
||
const permittedIds = new Set(effective.allowedConnectionIds);
|
||
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) || Boolean(restConnectorFor(connection)))
|
||
&& !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.getEffectiveToolAssignmentsForAgent(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 (app.experimentalFeatureKey && !isConnectorFeatureEnabled(app, await instanceSettingsService(db).getExperimental())) {
|
||
throw notFound("Connection service was not found");
|
||
}
|
||
if (purpose === "channel" && !getAvailableConnectionMethods(app).some(method => method.purpose === "channel")) {
|
||
throw unprocessable("This channel 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);
|
||
const definition = application.applicationKey != null
|
||
? getAppDefinitionForApplicationKey(application.applicationKey)
|
||
: typeof connection.config?.sourceTemplateKey === "string"
|
||
? getAppStoreDefinition(connection.config?.sourceTemplateKey) : null;
|
||
if (definition?.experimentalFeatureKey && !isConnectorFeatureEnabled(definition,
|
||
await instanceSettingsService(db).getExperimental())) {
|
||
throw notFound("Configured connection was not found");
|
||
}
|
||
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>>>();
|
||
// New setup of these native services is offered through aggregators instead.
|
||
const yieldsToAggregators = createNativeConnectorYield(settings);
|
||
const yieldingServices = new Set<string>();
|
||
const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app)
|
||
.filter(method => method.purpose !== "channel" || app.slug === "agentmail"
|
||
|| (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors));
|
||
const services = [...APP_STORE_DEFINITIONS.filter(app => isConnectorFeatureEnabled(app, settings)
|
||
&& 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;
|
||
if (definition && !matching.length && yieldsToAggregators(definition)) yieldingServices.add(service);
|
||
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);
|
||
const methods = await Promise.all(app.methods.map(async method => {
|
||
const declaredPurpose = definition ? discoveryMethods(definition).find(candidate => candidate.key === method.key)?.purpose : undefined;
|
||
const purpose = declaredPurpose === "channel" ? "chat" : declaredPurpose === "ai" ? "ai" : "tool";
|
||
const capability = await connectionManagementService(db).capability({ type: "agent", companyId: run.companyId,
|
||
agentId: agent.id, runId: run.id, onBehalfOfUserId: run.responsibleUserId }, "connections:configure", {
|
||
type: "connection_management", companyId: run.companyId, purpose, provider: service, connectionId: ready?.id,
|
||
operation: "configure", selfServiceUserId: ready?.createdByUserId ?? (purpose === "ai" ? run.responsibleUserId : null),
|
||
});
|
||
const toolAccess = ready && purpose === "tool" ? await (await import("./tool-gateway.js")).createToolGatewayService(db).summarizeConnectionAccessForAgent({
|
||
companyId: run.companyId, connectionId: ready.id, agentId: agent.id, heartbeatRunId: run.id,
|
||
issueId: issue?.id,
|
||
}) : null;
|
||
return { ...method, management: { ...capability, canUse: toolAccess ? toolAccess.allowedCount > 0 : Boolean(ready),
|
||
requiresApproval: Boolean(toolAccess?.askFirstCount), requiresHumanAuthentication: method.auth !== "none" && !ready,
|
||
nextApiOperation: purpose === "chat" ? "POST /api/companies/{companyId}/chat-endpoints"
|
||
: purpose === "ai" ? "POST /api/companies/{companyId}/ai-connections" : "POST /api/companies/{companyId}/tools/apps/connect" } };
|
||
}));
|
||
candidates.push({ score, nameScore, item: {
|
||
service, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null,
|
||
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,
|
||
}});
|
||
}
|
||
if (!settings.enableChatConnectors) for (const app of APP_STORE_DEFINITIONS) {
|
||
if (!isConnectorFeatureEnabled(app, settings) || discoveryMethods(app).length || findAggregatorService(app.slug) || findAggregatorService(app.name)
|
||
|| !getAvailableConnectionMethods(app).some(method => method.purpose === "channel" && method.provider !== "github")) continue;
|
||
const channelMethods = getAvailableConnectionMethods(app).filter(method => method.purpose === "channel");
|
||
const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name],
|
||
`${app.description ?? ""} ${channelMethods.map(method => `${method.whenToUse} ${method.label ?? ""}`).join(" ")}`);
|
||
if (!score) continue;
|
||
candidates.push({ score, nameScore, item: {
|
||
service: app.slug, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null,
|
||
methods: [], source: "catalog", state: "unavailable", connectionId: null,
|
||
reason: `${app.name} chat is turned off on this instance. An instance admin turns it on with Chat connectors on [Experimental settings](/${company.prefix}/company/settings/instance/experimental); `
|
||
+ "then a person who manages connections can set it up. Agents cannot change instance settings. "
|
||
+ "Tell the person this, link that page and reply; do not create a chat endpoint, substitute another channel or search for another route.",
|
||
}});
|
||
}
|
||
// A yielded native service is not offered for new setup when an aggregator
|
||
// route can serve it; its routes take its place in ranked results, so broad
|
||
// and capability-only searches still find the app. Without a route the
|
||
// native service remains the fallback.
|
||
const yieldedRoutes: typeof candidates = [];
|
||
for (const service of yieldingServices) {
|
||
const candidate = candidates.find(({ item }) => item.service === service);
|
||
const routes = candidate ? await aggregatorAlternatives(service, candidate.item.name, findAggregatorService(service)) : [];
|
||
if (!routes.length) continue;
|
||
candidates.splice(candidates.indexOf(candidate!), 1);
|
||
yieldedRoutes.push(...routes.map(item => ({ item, score: candidate!.score, nameScore: candidate!.nameScore })));
|
||
}
|
||
const publicMatches = searchAggregatorServices(preparedQuery);
|
||
const bestMatches = publicMatches.filter(match => Math.floor(match.nameScore / 100) === Math.floor((publicMatches[0]?.nameScore ?? 0) / 100));
|
||
const namedPublicService = findAggregatorService(serviceQuery, publicMatches);
|
||
const publicService = namedPublicService ?? (bestMatches.length === 1 ? bestMatches[0]!.service : undefined);
|
||
// Extract service namespaces only from the current identity's indexed tools.
|
||
// Generic provider search/execute descriptions do not prove app support.
|
||
const indexedServices = new Set<string>();
|
||
for (const connection of inventory.connections) {
|
||
const provider = sourceSlugForConnection(connection, inventory.applicationsById);
|
||
if (!isRemoteMcpConnectorId(provider) || !connection.enabled || connection.status === "archived") continue;
|
||
for (const entry of authorizedCatalogs.get(connection.id) ?? []) {
|
||
const namespace = entry.toolName.toLowerCase().match(/^([a-z0-9-]+)[_.:]/)?.[1];
|
||
if (namespace && !isRemoteMcpConnectorId(namespace)) indexedServices.add(namespace);
|
||
if (provider === "executor" && entry.toolName === "execute") {
|
||
for (const line of (entry.description ?? "").split("\n")) {
|
||
const slug = line.trim().match(/^- `([a-z0-9][a-z0-9-]{0,79})`$/)?.[1];
|
||
if (slug) indexedServices.add(slug);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const indexedMatches = [...indexedServices].map(slug => ({ slug, ...scoreConnectionSearch(preparedQuery, [slug]) }))
|
||
.filter(match => match.nameScore > 0).sort((a, b) => b.score - a.score);
|
||
const bestExternalTier = Math.floor(Math.max(publicMatches[0]?.nameScore ?? 0, indexedMatches[0]?.nameScore ?? 0) / 100);
|
||
// Native preference applies to the same app or equally strong name matches.
|
||
// A typo match such as Notion must not hide the distinctly named app Motion.
|
||
const exact = candidates.filter(({ item, nameScore }) => ((nameScore >= 200 && Math.floor(nameScore / 100) >= bestExternalTier) || item.service === publicService?.slug)
|
||
&& (!isRemoteMcpConnectorId(item.service) || (!publicService && !indexedMatches.length)));
|
||
const ranked = () => [...candidates, ...yieldedRoutes].sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name))
|
||
.slice(0, 40).map(({ item }) => item);
|
||
// Aggregator routes need a recorded provider choice before connection_request.
|
||
const rankedResult = (suggestions = false) => {
|
||
const results = ranked();
|
||
return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, company.prefix, suggestions);
|
||
};
|
||
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 rankedResult();
|
||
}
|
||
async function aggregatorAlternatives(targetService: string, targetName: string, publicService?: AggregatorServiceDefinition) {
|
||
const alternatives: ConnectionSearchResultItem[] = [];
|
||
if (/^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) {
|
||
for (const provider of AGGREGATOR_PRIORITY) {
|
||
// Broad execute/search descriptions are not evidence of app support. Only a
|
||
// namespaced action (or an explicitly listed Executor integration) qualifies.
|
||
const connections = inventory.connections.filter(connection => connection.status !== "archived" && connection.enabled && sourceSlugForConnection(connection, inventory.applicationsById) === provider);
|
||
let indexedAt: Date | undefined;
|
||
for (const connection of connections) {
|
||
const { grants } = await access.listConnectionGrants(connection.id, run.companyId);
|
||
if (!grants.some(grant => grant.status === "active" && (grant.kind === "organization"
|
||
|| grant.kind === "user" && grant.subjectUserId === run.responsibleUserId
|
||
|| grant.kind === "agent" && grant.subjectAgentId === agent.id))) continue;
|
||
const entries = authorizedCatalogs.get(connection.id) ?? [];
|
||
const matching = entries.filter(entry => {
|
||
const name = entry.toolName.toLowerCase();
|
||
return name.startsWith(targetService + "_") || name.startsWith(targetService + ".") || name.startsWith(targetService + ":")
|
||
|| provider === "executor" && entry.toolName === "execute"
|
||
&& (entry.description ?? "").split("\n").some(line => line.trim() === "- `" + targetService + "`");
|
||
});
|
||
for (const entry of matching) if (!indexedAt || entry.lastSeenAt > indexedAt) indexedAt = entry.lastSeenAt;
|
||
}
|
||
const publishedAt = publicService ? (publicService.providers as Partial<Record<string, string>>)[provider] : undefined;
|
||
const published = Boolean(publishedAt);
|
||
if (!published && !indexedAt) continue;
|
||
if (await administrativeDenial(run.companyId, agent.id, provider, inventory)) continue;
|
||
const app = await resolveService(provider, run.companyId, run.responsibleUserId!, agent.id);
|
||
if (!app.available || !app.methods.length) continue;
|
||
const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: provider, inventory });
|
||
alternatives.push({
|
||
service: `via:${provider}:${targetService}`, name: `${targetName} through ${app.name}`,
|
||
source: "aggregator", state: "available", description: `Connect ${targetName} through ${app.name}, an external service.`,
|
||
logoUrl: app.branding.logoUrl ?? null, methods: app.methods, connectionId: ready?.id ?? null,
|
||
reason: ready ? `${app.name} is connected; verify ${targetName} authorization and the requested action.`
|
||
: provider === "arcade" ? "Set up an Arcade gateway with this app's tools, then authorize the app."
|
||
: `Connect ${app.name}, then verify and authorize ${targetName}.`,
|
||
aggregator: { provider, targetService, targetName, evidenceUrl: published ? publicService?.evidenceUrls?.[provider] ?? AGGREGATOR_CATALOG_SOURCES[provider] ?? null : null,
|
||
verifiedAt: publishedAt ?? indexedAt!.toISOString(),
|
||
readiness: ready ? "requires_app_verification" : "requires_provider_setup" },
|
||
});
|
||
}
|
||
}
|
||
return alternatives;
|
||
}
|
||
const alternatives = await aggregatorAlternatives(targetService, targetName, publicService);
|
||
// A typo/prefix match is a discovery hint, not a selected app. Generic
|
||
// capabilities (e.g. "pages") can resemble a catalog name ("Page X").
|
||
// Retain authorized installed matches instead of prescribing provider consent.
|
||
const namedTarget = namedPublicService !== undefined
|
||
|| indexedMatches.some(match => match.slug === targetService && match.nameScore >= 500);
|
||
if (alternatives.length && !namedTarget) {
|
||
return discoverySuggestions([...ranked(), ...alternatives]);
|
||
}
|
||
if (explicit && explicitConsent) {
|
||
const selected = alternatives.find(item => item.aggregator?.provider === explicit.provider);
|
||
if (!selected) return { version: 1, query, results: [], instruction: "The explicitly requested external provider is unavailable or its app support could not be verified. Explain the limitation. Do not switch providers automatically." };
|
||
return { version: 1, query, results: [{ ...selected, service: explicit.provider }],
|
||
instruction: `The user explicitly requested ${AGGREGATOR_NAMES[explicit.provider]}. Disclose that this external service handles the connection and requests to ${targetName}. Do not ask another provider-choice question or substitute another provider. Call connection_request with service ${explicit.provider} and targetService ${targetService}. Follow its returned instruction; app authorization is not yet verified.` };
|
||
}
|
||
if (!alternatives.length && bestMatches.length > 1) {
|
||
const matches = (await Promise.all(bestMatches.slice(0, 10).map(({ service }) =>
|
||
aggregatorAlternatives(service.slug, service.name, service)))).flat();
|
||
if (matches.length) return discoverySuggestions([...ranked(), ...matches]);
|
||
}
|
||
if (!alternatives.length) return rankedResult(true);
|
||
const question = aggregatorProviderQuestion(targetService, targetName, alternatives);
|
||
const latest = previous[0];
|
||
if (latest && (latest.status === "pending" || !options.retryProviderChoice)) {
|
||
if (latest.status === "pending") return { version: 1, query, results: alternatives, instruction: "The provider-choice question is already pending. Finish independent work, then yield. Do not ask again.", selectionInteractionId: latest.id };
|
||
const choice = selectedProvider(latest, question);
|
||
if (choice === "none" || latest.status === "cancelled") return { version: 1, query, results: [], instruction: "The user declined external providers for this service. Do not connect or ask again. Explain that access remains unavailable. Only if the user explicitly asks to reconsider, search again with retryProviderChoice true." };
|
||
const selected = alternatives.find(item => item.service === choice);
|
||
if (selected) return { version: 1, query, results: [selected], selectionInteractionId: latest.id,
|
||
instruction: `The user selected ${selected.name}. Call connection_request with service ${selected.service} and selectionInteractionId ${latest.id}. Follow its returned instruction; underlying app access is not yet verified.` };
|
||
if (choice) return { version: 1, query, results: [], instruction: "The chosen external provider is no longer available for this service. Explain the restriction or missing support. Do not switch providers automatically. The user may explicitly request a new provider choice." };
|
||
}
|
||
// An agent's query is not proof of human choice. Keep its named provider as
|
||
// the sole confirmation option unless a saved user message proves consent.
|
||
const offered = explicit ? alternatives.filter(item => item.aggregator?.provider === explicit.provider) : alternatives;
|
||
if (!offered.length) return { version: 1, query, results: [], instruction: "The requested external provider is unavailable. Do not switch providers automatically." };
|
||
const providerQuestion = aggregatorProviderQuestion(targetService, targetName, offered);
|
||
return { version: 1, query, results: offered, providerQuestion, providerQuestionSet: aggregatorProviderQuestionSet(providerQuestion),
|
||
instruction: "No matching built-in Paperclip connection was found. Ask the responsible user with providerQuestion exactly as returned (including its id, full prompt, and options). With native request_human_input, use interactionKind questions, continuationPolicy wake_assignee, and payload {version:1, questionSet:providerQuestionSet} exactly as returned. Otherwise use ask_user_questions with payload {version:1, questions:[providerQuestion]}. These are external services. Wait for the saved answer; then call connection_request with the selected service identifier and selectionInteractionId set to the answered question interaction ID. None for now means do not connect. Do not claim app access yet." };
|
||
|
||
function discoverySuggestions(results: ConnectionSearchResultItem[]): ConnectionsSearchResult {
|
||
const seen = new Set<string>();
|
||
return { version: 1, query, results: results.filter(item => !seen.has(item.service) && Boolean(seen.add(item.service))).slice(0, 40),
|
||
instruction: "These are possible connection matches. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods and AgentMail use connection_request to show the inline setup card; other channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." };
|
||
}
|
||
}
|
||
|
||
function directSearchResult(query: string, results: ConnectionSearchResultItem[], prefix: string, suggestions = false): ConnectionsSearchResult {
|
||
const ownServer = `the person adds it as their own MCP server on [Connect your own MCP server](/${prefix}/apps/byo): they enter the URL and type any API key into that page's secure field themselves. `
|
||
+ "An agent cannot enter the key. Do not create the connection yourself, ask for the key in chat or search for another route; link that page and reply.";
|
||
return { version: 1, query, results, instruction: !results.length
|
||
? `No verified connection route was found. If the person gave the service's own server URL, ${ownServer} Otherwise 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.length === 1) && 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 user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. 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. If none of these is the service the person named and they gave that service's own server URL, ${ownServer}`
|
||
: 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. If method.management.allowed, configure through search_api/call_api using nextApiOperation. Hand off only required human authentication or consent at the saved setupPath. 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."
|
||
: "The user asked to connect. If method.management.allowed, use search_api/call_api with nextApiOperation to configure it. Reuse a saved draft. Use connection_request only for required human input or access. Do not ask a generic confirmation or silently change policy to bypass a failed tool call." };
|
||
}
|
||
|
||
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: { agentSetup?: true; agentSetupOperationId?: string; purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string; connectionId?: string; toolNames?: string[] } = {},
|
||
): Promise<ConnectionRequestResult> {
|
||
const claims = { sub: context.agent.id, company_id: context.run.companyId, run_id: context.run.id, responsible_user_id: context.run.responsibleUserId! };
|
||
const route = parseAggregatorRoute(serviceSlug);
|
||
let upstreamService: { slug: string; name: string; selectionInteractionId?: string } | undefined;
|
||
if (options.targetService) {
|
||
if (route || options.purpose || !isRemoteMcpConnectorId(serviceSlug)) throw unprocessable("An explicit target app requires a direct external-provider request");
|
||
const found = await search(claims, `${options.targetService} through ${serviceSlug}`);
|
||
const selected = found.results.find(item => item.aggregator?.provider === serviceSlug && (item.service === serviceSlug || Boolean(found.selectionInteractionId && !found.providerQuestion)));
|
||
if (!selected?.aggregator) throw forbidden("The requested provider cannot connect this app without verified support and a recorded user choice or explicit user request");
|
||
// Reuse the saved-answer validation below; a pending question also carries
|
||
// an interaction ID, but is never permission to create the setup card.
|
||
if (selected.service !== serviceSlug) return request(claims, selected.service, { ...options, targetService: undefined, selectionInteractionId: found.selectionInteractionId });
|
||
upstreamService = { slug: selected.aggregator.targetService, name: selected.aggregator.targetName };
|
||
}
|
||
if (route) {
|
||
if (options.purpose) throw unprocessable("Aggregator routes are tool connections only");
|
||
const found = await search(claims, route.targetService);
|
||
const selected = found.results.find(item => item.service === serviceSlug);
|
||
if (!selected?.aggregator || !options.selectionInteractionId || found.selectionInteractionId !== options.selectionInteractionId) {
|
||
throw forbidden("Choose an external provider using the returned provider question before requesting this connection");
|
||
}
|
||
const rows = await providerSelections(context.run.companyId, context.issue.id, context.agent.id, context.run.responsibleUserId!, `connection-provider:${route.targetService}`);
|
||
const row = rows.find(row => row.id === options.selectionInteractionId);
|
||
if (!row || row.status !== "answered" || row.resolvedByUserId !== context.run.responsibleUserId || row.resolvedByAgentId
|
||
|| found.results.length !== 1 || found.providerQuestion) throw forbidden("The provider choice is not a valid saved user answer");
|
||
upstreamService = { slug: route.targetService, name: selected.aggregator.targetName, selectionInteractionId: options.selectionInteractionId };
|
||
serviceSlug = route.provider;
|
||
}
|
||
// AgentMail has one channel method. Infer it from the server-owned catalog
|
||
// identifier instead of asking the model to invent a new tool argument.
|
||
if (serviceSlug === "agentmail" && !options.purpose) options = { ...options, purpose: "channel" };
|
||
const app = await resolveService(serviceSlug, context.run.companyId, context.run.responsibleUserId!, context.agent.id, options.purpose);
|
||
if (!app.available || app.methods.length === 0) {
|
||
throw unprocessable(`Connection service ${serviceSlug} is not available`);
|
||
}
|
||
let accessRequest: ConnectionIntentInteraction["payload"]["accessRequest"];
|
||
if (!options.purpose) {
|
||
const inventory = await connectionInventory(context.run.companyId);
|
||
const matching = inventory.connections.filter(connection =>
|
||
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
|
||
&& (!options.connectionId || connection.id === options.connectionId)
|
||
&& connection.connectionPurpose !== "ai" && connection.status === "active" && connection.enabled
|
||
&& !isToolConnectionAttentionHealth(connection.healthStatus));
|
||
const eligible = (await Promise.all(matching.map(async connection => {
|
||
const { grants } = await access.listConnectionGrants(connection.id, context.run.companyId);
|
||
return grants.some(grant => grant.status === "active" && (
|
||
grant.kind === "organization" || grant.subjectUserId === context.run.responsibleUserId
|
||
|| (grant.kind === "agent" && grant.subjectAgentId === context.agent.id))) ? connection : null;
|
||
}))).filter((connection): connection is ToolConnection => Boolean(connection));
|
||
if (options.connectionId && !eligible.length) throw notFound("The saved connection is not eligible for this request");
|
||
if (eligible.length === 1) {
|
||
const connection = eligible[0]!;
|
||
const catalog = (await indexedCatalog(connection.id, context.run.companyId)).filter(tool => tool.entryKind === "tool");
|
||
const names = options.toolNames ?? (app.slug === "composio" ? ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] : catalog.map(tool => tool.toolName));
|
||
const tools = names.map(name => catalog.find(tool => tool.toolName === name));
|
||
if (options.toolNames && tools.some(tool => !tool)) {
|
||
// Return discovery metadata only after validating this connection's
|
||
// eligibility. Never substitute guessed names or grant access here.
|
||
const availableNames = catalog.map(tool => tool.toolName).sort();
|
||
throw unprocessable(`A requested tool is not in this connection's active catalog. Available indexed tool names (first ${Math.min(20, availableNames.length)} of ${availableNames.length}): ${JSON.stringify(availableNames.slice(0, 20))}. Request only the needed exact names with connection_request. No access was granted.`);
|
||
}
|
||
const effective = await access.getEffectiveToolAssignmentsForAgent(context.run.companyId, context.agent.id);
|
||
const installed = effective.installedConnections.some(item => item.id === connection.id);
|
||
// Without toolNames the agent is asking whether it can use the service at all.
|
||
// Existing scoped access answers that; only named tools can request more.
|
||
const missing = !installed || (options.toolNames
|
||
? tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id))
|
||
: !effective.allowedTools.some(allowed => allowed.connectionId === connection.id));
|
||
if (missing && tools.length && tools.every(tool => Boolean(tool))) {
|
||
// Larger catalogs exceed the access-card bound. Keep scoped discovery
|
||
// actionable without granting access or creating an oversized card.
|
||
if (tools.length > 500) throw unprocessable(`Specify the tools needed for this task (up to 20). ${scopedToolRequestGuidance(connection.id, catalog)}`);
|
||
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,
|
||
...(options.agentSetup ? { agentSetup: true as const, agentSetupOperationId: options.agentSetupOperationId } : {}),
|
||
});
|
||
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,
|
||
...(options.agentSetup ? { agentSetup: true as const, agentSetupOperationId: options.agentSetupOperationId } : {}),
|
||
...(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}` : ""}${options.agentSetup ? `:setup:${options.agentSetupOperationId ?? "legacy"}` : ""}${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 requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"]; credentialFields?: string[] } = {}) {
|
||
const context = await loadRunContext(claims);
|
||
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.companyId, context.run.companyId)));
|
||
if (!endpoint || endpoint.assignedAgentId !== context.agent.id) throw forbidden("This setup is not assigned to the requesting agent");
|
||
await connectionManagementService(db).authorize({ type: "agent", companyId: context.run.companyId,
|
||
agentId: context.agent.id, runId: context.run.id, onBehalfOfUserId: context.run.responsibleUserId,
|
||
}, "connections:configure", { type: "connection_management", companyId: context.run.companyId,
|
||
connectionId: endpoint.connectionId, provider: endpoint.provider, purpose: "chat", operation: "chat.authentication_handoff" });
|
||
const credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => !options.credentialFields || options.credentialFields.includes(field));
|
||
if (options.action === "enter_credentials" && !credentialFields.length) throw unprocessable("Credential entry requires a supported verification field");
|
||
const generations = chatCredentialEntryGenerations(endpoint.setup);
|
||
const interaction = await interactions.createConnectionIntent(context.issue, {
|
||
payload: { version: 1, serviceSlug: endpoint.provider, serviceName: endpoint.provider === "github" ? "GitHub" : endpoint.provider === "slack" ? "Slack" : endpoint.provider,
|
||
purpose: "channel", savedSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId,
|
||
...(options.action ? { action: options.action } : {}),
|
||
...(options.action === "enter_credentials" ? { credentialEntry: { previousGenerations: Object.fromEntries(credentialFields.map(field => [field, generations[field]])), requireAll: !!options.credentialFields } } : {}),
|
||
...(options.action === "rotate_credentials" ? { credentialRotation: { previousId: chatCredentialRotationId(endpoint.setup) } } : {}) },
|
||
requestingAgentId: context.agent.id, requestingAgentName: context.agent.name, phase: "authorizing" },
|
||
sourceRunId: context.run.id, sourceIdentityContextId: context.run.activeIdentityContextId,
|
||
addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" || options.action === "enter_credentials" ? `:${context.run.id}` : ""}${options.action === "enter_credentials" ? `:${options.credentialFields ? "all" : "any"}:${credentialFields.join(",")}` : ""}`,
|
||
});
|
||
if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome.");
|
||
return { interactionId: interaction.id, previousRotationId: connectionIntentPayloadSchema.parse(interaction.payload).savedSetup?.credentialRotation?.previousId ?? null };
|
||
}
|
||
|
||
async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise<ConnectionIntentSetupOptions> {
|
||
const loaded = await loadIntent(interactionId);
|
||
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
|
||
if (payload.savedSetup) {
|
||
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, payload.savedSetup.endpointId), eq(chatEndpoints.companyId, loaded.issue.companyId), eq(chatEndpoints.connectionId, payload.savedSetup.connectionId)));
|
||
if (!endpoint || endpoint.assignedAgentId !== payload.requestingAgentId) throw notFound("Saved chat setup not found");
|
||
if (!loaded.interaction.addresseeUserId || endpoint.sponsorUserId !== loaded.interaction.addresseeUserId) throw notFound("Saved chat setup not found");
|
||
await connectionManagementService(db).authorize({ type: "board", userId: loaded.interaction.addresseeUserId, source: "session", ignoreInstanceAdmin: true }, "connections:configure", {
|
||
type: "connection_management", companyId: loaded.issue.companyId, connectionId: endpoint.connectionId,
|
||
targetAgentId: endpoint.assignedAgentId, purpose: "chat", operation: "chat.authentication_handoff",
|
||
});
|
||
const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, loaded.issue.companyId));
|
||
return { version: 1, interaction: loaded.interaction, requestedAgentId: payload.requestingAgentId, existingConnections: [],
|
||
service: { service: payload.serviceSlug, name: payload.serviceName, description: null, logoUrl: null, methods: [], state: "needs_user_action", connectionId: endpoint.connectionId },
|
||
chatSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId,
|
||
path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action, payload.savedSetup.credentialRotation?.previousId, {
|
||
fields: payload.savedSetup.credentialEntry ? Object.keys(payload.savedSetup.credentialEntry.previousGenerations) : undefined,
|
||
requireAll: payload.savedSetup.credentialEntry?.requireAll,
|
||
}) },
|
||
};
|
||
}
|
||
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, agentSetup: payload.agentSetup, agentSetupOperationId: payload.agentSetupOperationId }) : null;
|
||
if (payload.purpose === "ai" && !managed) {
|
||
const [source] = await db.select().from(heartbeatRuns).where(and(
|
||
eq(heartbeatRuns.id, loaded.interaction.sourceRunId!), eq(heartbeatRuns.companyId, loaded.issue.companyId),
|
||
eq(heartbeatRuns.agentId, payload.requestingAgentId),
|
||
));
|
||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId)));
|
||
if (source?.status === "failed" && isAiAuthenticationRepairable(source) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) {
|
||
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, source) });
|
||
}
|
||
}
|
||
if (payload.purpose === "ai" && !managed) throw conflict("The agent’s AI configuration changed. Start a new execution.");
|
||
const inventory = await connectionInventory(loaded.issue.companyId);
|
||
let usableAiConnection = managed ? await usableConnectionForAgent({
|
||
companyId: loaded.issue.companyId, agentId: payload.requestingAgentId,
|
||
responsibleUserId: loaded.interaction.addresseeUserId!, serviceSlug: app.slug, purpose: "ai", sourceRunId: loaded.interaction.sourceRunId, agentSetup: payload.agentSetup, agentSetupOperationId: payload.agentSetupOperationId,
|
||
}) : 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 };
|
||
/** Only the verified OAuth callback applies a saved draft audience. */
|
||
oauthCompletion?: boolean;
|
||
} = {},
|
||
) {
|
||
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");
|
||
}
|
||
const selectedSetup = connectionIntentPayloadSchema.parse(current.payload).setupAccess;
|
||
const setupAccess = options.oauthCompletion && selectedSetup?.connectionId === connectionId ? selectedSetup.access : undefined;
|
||
if (setupAccess && !options.canManageOrganizationGrant && (
|
||
setupAccess === "all_agents" || setupAccess.agentIds.some(id => id !== payload.requestingAgentId)
|
||
)) throw forbidden("Sharing a connection with other agents requires connection-management authority");
|
||
if (setupAccess && setupAccess !== "all_agents" && !setupAccess.agentIds.includes(payload.requestingAgentId)) {
|
||
throw conflict("Select the requesting agent or all agents to continue this task");
|
||
}
|
||
if (setupAccess && setupAccess !== "all_agents") await assertAgentConnectionInstallAccess(txDb,
|
||
{ type: "board", userId, source: options.bypassCurrentMembershipCheck ? "local_implicit" : "session" },
|
||
loaded.issue.companyId, setupAccess.agentIds);
|
||
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: connectionGrants.id }).from(connectionGrants)
|
||
.where(and(eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, loaded.issue.companyId)))
|
||
.orderBy(connectionGrants.id).for("update");
|
||
await lockToolConnectionRuntimeInputs(tx, loaded.issue.companyId, connectionId);
|
||
let selectedConnection = await txAccess.getConnection(connectionId, loaded.issue.companyId);
|
||
const selectedApplication = await txAccess.getApplication(
|
||
selectedConnection.applicationId,
|
||
loaded.issue.companyId,
|
||
);
|
||
if (sourceSlugForConnection(
|
||
selectedConnection,
|
||
new Map([[selectedApplication.id, selectedApplication]]),
|
||
) !== payload.serviceSlug) {
|
||
throw notFound("Connection does not match this intent");
|
||
}
|
||
if (selectedConnection.status !== "active" || !selectedConnection.enabled || isToolConnectionAttentionHealth(selectedConnection.healthStatus)) {
|
||
throw conflict("Finish and test this connection before using it for the task");
|
||
}
|
||
|
||
if (payload.purpose === "channel") {
|
||
if (payload.serviceSlug !== "agentmail" || !options.canManageOrganizationGrant) {
|
||
throw forbidden("AgentMail inbox setup requires connection-management authority");
|
||
}
|
||
const inboxes = await assignedAgentmailInboxes(txDb, loaded.issue.companyId, payload.requestingAgentId);
|
||
const inbox = inboxes.find(inbox => inbox.connectionId === selectedConnection.id);
|
||
if (!inbox?.address) throw conflict("Finish creating an active inbox for the requesting agent before continuing");
|
||
const credentialId = selectedConnection.config?.credentialConnectionId;
|
||
if (typeof credentialId !== "string") throw conflict("Use a saved AgentMail account for this inbox");
|
||
await emailConnectionService(txDb).get(loaded.issue.companyId, credentialId, { userId });
|
||
return txInteractions.resolveConnectionIntent(loaded.issue, interactionId, {
|
||
version: 1, outcome: "connected", connectionId: selectedConnection.id,
|
||
instruction: "Your AgentMail inbox is ready. Use agentmail_inboxes to read its verified address and continue the user's request.",
|
||
}, { userId });
|
||
}
|
||
if (selectedConnection.connectionPurpose === "channel") throw conflict("An email inbox cannot satisfy a tool connection request");
|
||
if (payload.purpose === "ai" && selectedConnection.connectionPurpose !== "ai") throw conflict("Select an AI account for this authentication request");
|
||
if (selectedConnection.connectionPurpose === "ai") {
|
||
if (payload.purpose !== "ai") throw conflict("AI authentication cannot satisfy a tool connection request");
|
||
let managed;
|
||
if (options.validatedAdoption) {
|
||
await tx.select({ id: agents.id }).from(agents)
|
||
.where(and(eq(agents.companyId, loaded.issue.companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`))
|
||
.orderBy(agents.id).for("no key update");
|
||
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId))).for("no key update");
|
||
if (!agent || agent.runtimeConfig.aiConnection || agent.updatedAt.getTime() !== options.validatedAdoption.agentUpdatedAt.getTime()) {
|
||
throw conflict("The agent changed during validation. Reload the task and try again.");
|
||
}
|
||
const binding = options.validatedAdoption.binding;
|
||
if (binding.provider !== payload.serviceSlug || binding.mode !== "responsible_user") throw conflict("Invalid legacy adoption binding");
|
||
const updated = await updateAgentConfigurationInTransaction(txDb, agent.id, {
|
||
runtimeConfig: { ...agent.runtimeConfig, aiConnection: binding },
|
||
}, { recordRevision: { createdByUserId: userId, source: "patch" } });
|
||
if (!updated) throw notFound("Agent not found");
|
||
await logActivity(txDb, {
|
||
companyId: loaded.issue.companyId, actorType: "user", actorId: userId,
|
||
action: "agent.updated", entityType: "agent", entityId: agent.id,
|
||
details: { connectionIntentId: interactionId, aiConnectionAdopted: true },
|
||
});
|
||
managed = { agent: updated, binding, adapterConfig: updated.adapterConfig, requiresAdoption: false };
|
||
} else {
|
||
managed = await managedAgent(loaded.issue.companyId, payload.requestingAgentId, payload.serviceSlug, { sourceRunId: loaded.interaction.sourceRunId, agentSetup: payload.agentSetup, agentSetupOperationId: payload.agentSetupOperationId }, 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
|
||
);
|
||
const sharedSetup = Boolean(setupAccess && selectedConnection.credentialPolicy === "shared");
|
||
if (!payload.accessRequest && selectedConnection.authKind === "oauth" && (pendingPersonalGrant || sharedSetup)) {
|
||
// 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: sharedSetup ? "organization" : "user" },
|
||
{ actorType: "user", actorId: userId },
|
||
payload.requestingAgentId,
|
||
setupAccess,
|
||
);
|
||
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 === "company" || (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.getEffectiveToolAssignmentsForAgent(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; mutationOptions?: InteractionResolutionMutationOptions } = {},
|
||
) {
|
||
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 },
|
||
options.mutationOptions,
|
||
);
|
||
}
|
||
|
||
// Controller entry point for accepted work that cannot yet reach run preparation.
|
||
// Runtime tokens still require a running run. No credential is read or granted.
|
||
async function requestForQueuedAgentSetup(companyId: string, agentId: string) {
|
||
const [agent] = await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
||
if (!agent || !isAgentAwaitingSetup(agent)) return;
|
||
const [company] = await db.select({ defaultResponsibleUserId: companies.defaultResponsibleUserId })
|
||
.from(companies).where(eq(companies.id, companyId));
|
||
// Match host verification's account owner. The queued task can have a
|
||
// different responsible user; its execution identity must stay unchanged.
|
||
const verificationUserId = agent.lifecycleOperation?.responsibleUserId ?? company?.defaultResponsibleUserId;
|
||
if (!verificationUserId) return;
|
||
await assertCurrentUserWriteAccess(companyId, verificationUserId);
|
||
const managed = await queuedSetupManagedAgent(agent);
|
||
if (!managed) return;
|
||
const { binding, adapterConfig } = managed;
|
||
const operationId = text(agent.lifecycleOperation?.id);
|
||
const candidates = await db.select({ run: heartbeatRuns, issue: issues }).from(heartbeatRuns).innerJoin(issues, and(
|
||
eq(issues.companyId, heartbeatRuns.companyId),
|
||
sql`${issues.id}::text = coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId')`,
|
||
)).where(and(
|
||
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId),
|
||
eq(heartbeatRuns.status, "queued"), isNull(heartbeatRuns.startedAt), eq(heartbeatRuns.runtimeMode, "legacy"),
|
||
isNull(heartbeatRuns.activeIdentityContextId), eq(issues.assigneeAgentId, agentId),
|
||
notInArray(issues.status, ["done", "cancelled"]),
|
||
)).orderBy(desc(heartbeatRuns.createdAt)).limit(100);
|
||
const seen = new Set<string>();
|
||
for (const { run, issue } of candidates) {
|
||
const issueId = text(run.contextSnapshot?.issueId) ?? text(run.contextSnapshot?.taskId);
|
||
if (!issueId || !run.responsibleUserId || seen.has(issueId)) continue;
|
||
seen.add(issueId);
|
||
const [pending] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
|
||
eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.issueId, issueId),
|
||
eq(issueThreadInteractions.createdByAgentId, agentId), eq(issueThreadInteractions.addresseeUserId, verificationUserId),
|
||
eq(issueThreadInteractions.kind, "connection_intent"), eq(issueThreadInteractions.status, "pending"),
|
||
eq(issueThreadInteractions.sourceRunId, run.id),
|
||
operationId ? sql`${issueThreadInteractions.payload}->>'agentSetupOperationId' = ${operationId}` : undefined,
|
||
sql`${issueThreadInteractions.payload}->>'agentSetup' = 'true'`,
|
||
sql`${issueThreadInteractions.payload}->>'serviceSlug' = ${binding.provider}`,
|
||
)).limit(1);
|
||
if (pending) continue;
|
||
if (!await canActorReadIssuePrivacy(db, { type: "board", userId: verificationUserId, ignoreInstanceAdmin: true }, issue)) continue;
|
||
try {
|
||
const context = await loadRunContext({ sub: agentId, company_id: companyId, run_id: run.id,
|
||
responsible_user_id: run.responsibleUserId }, "queued_setup");
|
||
try {
|
||
await aiConnectionService(db).select({ companyId, agentId, userId: verificationUserId,
|
||
adapterType: agent.adapterType, runnerProvider: adapterConfig.provider,
|
||
acpxAgent: adapterConfig.acpxAgent, model: adapterConfig.model, binding });
|
||
} catch (error) {
|
||
if (!readAiConnectionConfigurationFailure(error)) throw error;
|
||
// This context selects the card recipient only. Do not rewrite the
|
||
// queued run, its task principal, or its saved execution input.
|
||
const cardContext = { ...context, run: { ...context.run, responsibleUserId: verificationUserId } };
|
||
await requestWithContext(cardContext, binding.provider, { purpose: "ai", agentSetup: true, agentSetupOperationId: operationId ?? undefined });
|
||
}
|
||
} catch (error) {
|
||
// A task may stop or change owners during this bounded active-work pass.
|
||
if (!(error instanceof HttpError) || ![403, 404, 409].includes(error.status)) throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
validate: (claims: ConnectionRunClaims) => loadRunContext(claims),
|
||
requestForQueuedAgentSetup,
|
||
usableConnectionForAgent,
|
||
search,
|
||
request,
|
||
requestChatHandoff,
|
||
// Controller-only entry point. Runtime tokens still require a running run.
|
||
requestForRunAuthFailure: async (runId: string) => {
|
||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||
if (!run || run.status !== "failed" || !isAiAuthenticationRepairable(run) || !run.responsibleUserId) return null;
|
||
const context = await loadRunContext({ sub: run.agentId, company_id: run.companyId, run_id: run.id, responsible_user_id: run.responsibleUserId }, "failed_auth");
|
||
const [latest] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||
eq(heartbeatRuns.companyId, run.companyId),
|
||
sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId', ${heartbeatRuns.nativeIssueId}::text) = ${context.issue.id}`,
|
||
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1);
|
||
if (latest?.id !== run.id) return null;
|
||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)));
|
||
if (!agent) return null;
|
||
const saved = aiConnectionBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
|
||
const selected = router ? record(run.contextSnapshot?.aiRouterSelection) : null;
|
||
const managed = router && selected ? await managedAgent(run.companyId, run.agentId, String(record(selected.binding)?.provider), { sourceRunId: run.id }) : null;
|
||
const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, run);
|
||
if (!binding) return null;
|
||
const attribution = record(run.contextSnapshot?.aiConnection);
|
||
if (attribution && attribution.provider !== binding.provider) return null;
|
||
if ((saved || managed) && attribution && typeof attribution.identity === "string" && typeof attribution.connectionId === "string" && typeof attribution.grantId === "string") {
|
||
await aiConnectionService(db).markAuthenticationFailed({ companyId: run.companyId, runId: run.id, agentId: run.agentId,
|
||
runStartedAt: run.startedAt ?? run.createdAt,
|
||
attribution: attribution as unknown as AiConnectionAttribution & { identity: string } });
|
||
}
|
||
return requestWithContext(context, binding.provider, { purpose: "ai" });
|
||
},
|
||
loadIntent,
|
||
setupOptions,
|
||
complete,
|
||
decline,
|
||
updatePhase: async (
|
||
interactionId: string,
|
||
phase: "requested" | "authorizing" | "needs_retry",
|
||
userId: string,
|
||
options: {
|
||
bypassCurrentMembershipCheck?: boolean;
|
||
canManageOrganizationGrant?: boolean;
|
||
setupAccess?: ConnectionIntentInteraction["payload"]["setupAccess"];
|
||
} = {},
|
||
) => {
|
||
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,
|
||
);
|
||
const setupAccess = connectionIntentPayloadSchema.shape.setupAccess.parse(options.setupAccess);
|
||
if (setupAccess) {
|
||
if (loaded.interaction.payload.purpose || loaded.interaction.payload.accessRequest) {
|
||
throw unprocessable("Only tool connection setup can select a new audience");
|
||
}
|
||
await access.getConnection(setupAccess.connectionId, loaded.issue.companyId);
|
||
if (phase === "authorizing" && setupAccess.access !== "all_agents" && !setupAccess.access.agentIds.includes(loaded.interaction.payload.requestingAgentId)) {
|
||
throw conflict("Select the requesting agent or all agents to continue this task");
|
||
}
|
||
if (setupAccess.access !== "all_agents") await assertAgentConnectionInstallAccess(db,
|
||
{ type: "board", userId, source: options.bypassCurrentMembershipCheck ? "local_implicit" : "session" },
|
||
loaded.issue.companyId, setupAccess.access.agentIds);
|
||
if (!options.canManageOrganizationGrant && (setupAccess.access === "all_agents"
|
||
|| setupAccess.access.agentIds.some(id => id !== loaded.interaction.payload.requestingAgentId))) {
|
||
throw forbidden("Sharing a connection with other agents requires connection-management authority");
|
||
}
|
||
}
|
||
return interactions.updateConnectionIntentPhase(loaded.issue, interactionId, phase, { userId }, setupAccess);
|
||
},
|
||
};
|
||
}
|