fix(connections): keep agent access consistent through setup and permission changes (#19352)

Task-hosted tool setup kept requester-only defaults, and audience edits could leave runtime installations restricted after permission bindings were widened. Large provider catalogs could also fail before creating an access card.

Use the normal agent audience defaults for new tool setup, preserve explicit selections through verified OAuth and draft resumption, and synchronize permission bindings and runtime installations atomically. Enforce agent-edit authority and preserve existing access during additive account reuse.

Validation: 915 focused regression tests, typechecks, and builds passed. All 54 CI checks passed and Greptile Apex scored the reviewed head 5/5 with no unresolved threads. Broad local test failures, recoveries, and incomplete heartbeat coverage are documented in the PR.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-05 17:38:32 -08:00
1 parent 36643d9378
commit 8f79a32898
27 files changed
+489 -91

No files matched your search

+45 -10
View File
@@ -1,4 +1,4 @@
import { connectionManagementService } from "./connection-management.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";
@@ -850,10 +850,9 @@ export function connectionIntentService(db: Db) {
? 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))) {
// Without this guidance an agent outside the original grant (for example,
// one hired before GitHub was connected) cannot discover what to request
// and reports the company's connection as unavailable.
if (tools.length > 20) throw unprocessable(`Specify the tools needed for this task (up to 20). ${scopedToolRequestGuidance(connection.id, catalog)}`);
// 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,
@@ -1100,6 +1099,8 @@ export function connectionIntentService(db: Db) {
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);
@@ -1135,6 +1136,17 @@ export function connectionIntentService(db: Db) {
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");
@@ -1221,16 +1233,18 @@ export function connectionIntentService(db: Db) {
const pendingPersonalGrant = grants.find((grant) =>
grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId
);
if (!payload.accessRequest && selectedConnection.authKind === "oauth" && pendingPersonalGrant) {
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: "user" },
{ grantKind: sharedSetup ? "organization" : "user" },
{ actorType: "user", actorId: userId },
payload.requestingAgentId,
setupAccess,
);
selectedConnection = await txAccess.getConnection(
selectedConnection.id,
@@ -1270,7 +1284,7 @@ export function connectionIntentService(db: Db) {
);
const requestedInstall = { targetType: "agent" as const, targetId: payload.requestingAgentId };
const additiveInstalls = installs.some((install) =>
install.targetType === requestedInstall.targetType && install.targetId === requestedInstall.targetId
install.targetType === "company" || (install.targetType === requestedInstall.targetType && install.targetId === requestedInstall.targetId)
) ? installs : [...installs, requestedInstall];
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: additiveInstalls }, {
actorType: "user",
@@ -1368,7 +1382,11 @@ export function connectionIntentService(db: Db) {
interactionId: string,
phase: "requested" | "authorizing" | "needs_retry",
userId: string,
options: { bypassCurrentMembershipCheck?: boolean } = {},
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");
@@ -1377,7 +1395,24 @@ export function connectionIntentService(db: Db) {
userId,
options.bypassCurrentMembershipCheck,
);
return interactions.updateConnectionIntentPhase(loaded.issue, interactionId, phase, { userId });
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);
},
};
}
@@ -40,6 +40,20 @@ export function responsibleConnectionUser(actor: AuthorizationActor): string | n
return (actor.type === "agent" ? actor.onBehalfOfUserId : actor.userId) ?? null;
}
/** Installation changes require the same agent-edit authority at every entry point. */
export async function assertAgentConnectionInstallAccess(db: Db, actor: AuthorizationActor, companyId: string, agentIds: string[]) {
const authorization = authorizationService(db);
for (const agentId of new Set(agentIds)) {
const [agent] = await db.select({ id: agents.id }).from(agents)
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).limit(1);
if (!agent) throw forbidden("The target agent is not available in this company");
const decision = await authorization.decide({
actor, action: "agent_config:update", resource: { type: "agent", companyId, agentId }, scope: { targetAgentId: agentId },
});
if (!decision.allowed) throw forbidden(`You cannot edit agent ${agentId}, so you cannot change its connection installs`);
}
}
/** Used at HTTP and service boundaries; capability probes call the same evaluator. */
export function connectionManagementService(db: Db) {
const authorization = authorizationService(db);
@@ -2950,6 +2950,7 @@ export function issueThreadInteractionService(
interactionId: string,
phase: ConnectionIntentInteraction["payload"]["phase"],
actor: InteractionActor,
setupAccess?: ConnectionIntentInteraction["payload"]["setupAccess"],
) => {
const current = await getPendingInteractionForResolution({
issue,
@@ -2961,10 +2962,10 @@ export function issueThreadInteractionService(
);
}
assertInteractionResolutionAllowed(current, actor);
const payload = connectionIntentPayloadSchema.parse(current.payload);
connectionIntentPayloadSchema.parse(current.payload);
const [updated] = await db
.update(issueThreadInteractions)
.set({ payload: { ...payload, phase }, updatedAt: now() })
.set({ payload: sql`${issueThreadInteractions.payload} || ${JSON.stringify({ phase, ...(setupAccess ? { setupAccess } : {}) })}::jsonb`, updatedAt: now() })
.where(
and(
eq(issueThreadInteractions.id, interactionId),
+18 -6
View File
@@ -2,7 +2,7 @@ import { notifyGitHubCredentialChange } from "./github-connection-notifications.
import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js";
import { notifyToolConnectionHealth, writeToolConnectionHealthChange } from "./tool-connection-health-notifications.js";
import { connectionHealthSchedule } from "./tool-access/catalog/health-schedule.js";
import type { ConnectionManagementContext } from "./connection-management.js";
import { assertAgentConnectionInstallAccess, type ConnectionManagementContext } from "./connection-management.js";
import { connectionActorUserId } from "./tool-access/actor.js";
import { restConnectorFor } from "./connectors/rest-registry.js";
import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js";
@@ -3433,13 +3433,17 @@ export function toolAccessService(
}
const profileBindings: ToolProfileBinding[] = [];
// These connectors expose one agent-access choice. Commit installation
// reach and permission bindings together, including an empty selection.
if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
// Agent access is also the credential admission boundary. Save its
// installation and permission targets together for every tool connector.
if (connection.connectionPurpose === "tool") {
const desired = new Map(bindingInputs.flatMap((binding) => binding.targetType === "company" || binding.targetType === "agent"
? [[`${binding.targetType}:${binding.targetId}`, { targetType: binding.targetType, targetId: binding.targetId }] as const] : []));
const removed = existingInstalls.filter((install) => !desired.has(`${install.targetType}:${install.targetId}`));
const removed = input.preserveExistingAccess ? [] : existingInstalls.filter((install) => !desired.has(`${install.targetType}:${install.targetId}`));
const added = [...desired.values()].filter((binding) => !existingInstalls.some((install) => install.targetType === binding.targetType && install.targetId === binding.targetId));
if (actor?.managementContext) {
await assertAgentConnectionInstallAccess(tx as unknown as Db, actor.managementContext.actor, companyId,
[...removed, ...added].filter(target => target.targetType === "agent").map(target => target.targetId));
}
if (removed.length) await tx.delete(toolConnectionInstalls).where(inArray(toolConnectionInstalls.id, removed.map((install) => install.id)));
if (added.length) await tx.insert(toolConnectionInstalls).values(added.map((binding) => ({
companyId, connectionId: connection.id, targetType: binding.targetType, targetId: binding.targetId,
@@ -3533,7 +3537,15 @@ export function toolAccessService(
await notifyToolConnectionHealth(tx, connection);
const [updatedConnection] = await tx
.update(toolConnections)
.set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
.set({
status: "active", enabled: true,
...(connection.connectionPurpose === "tool" && connection.transport === "mcp_remote" ? {
config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
} : {}),
...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}),
updatedAt: new Date(),
})
.where(eq(toolConnections.id, connection.id))
.returning();
await tx
@@ -7,7 +7,7 @@ import { emitConnectionUpdated } from "../../connector-telemetry.js";
import { and, eq, inArray, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { connectionGrantMembers, connectionGrantDelegations, connectionGrants, companySecrets, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections } from "@paperclipai/db";
import type { FinishToolAppResult, FinalizeOAuthAccess, ToolCredentialSecretRef } from "@paperclipai/shared";
import type { FinishToolApp, FinishToolAppResult, FinalizeOAuthAccess, ToolCredentialSecretRef } from "@paperclipai/shared";
import { getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared";
import { badRequest, conflict, forbidden, unprocessable } from "../../../errors.js";
import { secretService } from "../../secrets.js";
@@ -40,6 +40,7 @@ async function finalizeOAuthAccess(
input: FinalizeOAuthAccess,
actor?: ActorInfo,
requestingAgentId?: string,
setupAccess?: FinishToolApp["access"],
): Promise<FinishToolAppResult> {
await actor?.managementContext?.reauthorize();
if (requestingAgentId)
@@ -418,14 +419,14 @@ async function finalizeOAuthAccess(
askFirstCatalogEntryIds: catalog
.filter((entry) => askFirstRiskLevels.has(entry.riskLevel))
.map((entry) => entry.id),
access: requestingAgentId
access: setupAccess ?? (requestingAgentId
? { agentIds: [requestingAgentId] }
: "all_agents",
: "all_agents"),
preserveExistingAccess: Boolean(requestingAgentId),
},
actor,
);
await db
if (!setupAccess) await db
.insert(toolConnectionInstalls)
.values({
companyId,