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

+9 -3
View File
@@ -37,10 +37,10 @@ The manually configured Paperclip MCP server also advertises `connections_search
- Company, agent, run, task, and responsible user come only from the signed legacy runtime token or the native server-owned binding and stored execution identity.
- Tokens are scoped to connection intents, expire after one hour, and are rejected when the heartbeat is no longer running.
- The thread payload contains only service identity, requesting-agent identity, and a safe phase. It never contains credentials or authorization URLs.
- The thread payload contains service identity, requesting-agent identity, a safe phase, and an optional human-selected audience bound to an OAuth draft. It never contains credentials or authorization URLs.
- OAuth state is linked to the interaction. The same-origin callback finalizes the existing connection pipeline, posts only interaction ID/outcome to its opener, and redirects back to the task if there is no opener.
- Personal OAuth defaults to the addressed user and creates an explicit delegation to the requesting agent. Reuse and installs are additive.
- Task-hosted setup locks install reach to the requesting agent; the store host retains its normal broader access choices.
- New tool setup uses the same all-agents default and audience controls in Apps and task dialogs. Broadening the audience requires current connection-management authority. AI authentication and inbox setup remain agent-scoped.
- The intent resolves only after the connection, grant/delegation, profile access, and install succeed. Failures remain pending with `needs_retry`.
- Closing or reassigning the task expires pending intents and deletes linked OAuth state. Ordinary comments and later runs preserve the pending card. Requests reuse the same task, requester, addressed user and service; a different addressed user supersedes an older request for that service.
- Success and explicit decline atomically persist a continuation delivery with resolution. A leased startup/periodic worker dispatches through heartbeat with a unique `connection-intent:<interaction>:<outcome>` wake key. It checks assignment, status, membership and current executable access, retries paused/suppressed delivery, and recovers a crash after enqueue without creating a second wake. The continuation forces a fresh provider session; heartbeat queues it behind active execution.
@@ -71,7 +71,13 @@ Catalog slugs remain stable. Search also returns `connection:<uuid>` for configu
`ready` requires an installed, enabled, healthy executable connection, permitted catalog tools, and a usable runtime identity. An installed connection with denied actions is administrative denial rather than a request to reauthenticate. Runtime calls continue enforcing access after a historical card resolves. If access is ready but the native provider's pinned tool snapshot is older, `connection_request` queues a fresh session without another authorization card.
Task setup defaults to personal identity when supported and the requesting agent's install reach. Existing installs are additive. An OAuth callback from a task prepares the catalog without adding access. Intent completion validates the current task and identity, then adds the requesting agent’s binding and install in the resolution transaction. Callback messages do not establish authorization: the card reloads the durable server result. A blocked popup offers a new-tab fallback; closing or declining provider sign-in keeps the request retryable. Only the card's **Not now** action declines the request. Decline continuations are told to pursue alternatives and cannot immediately request the same service again.
New tool setup uses the normal Apps identity and audience choices, including access for all agents. Before OAuth navigation, the addressed human saves the selected audience bound to the draft connection. This save grants no access. The callback prepares the catalog, and intent completion rechecks task ownership, membership and sharing authority before applying the audience and resolving the request atomically. Older clients without a saved audience retain requester-only completion. Reusing an existing account keeps access additive. Callback messages do not establish authorization: the card reloads the durable server result. A blocked popup offers a new-tab fallback; closing or declining provider sign-in keeps the request retryable. Only the card's **Not now** action declines the request. Decline continuations are told to pursue alternatives and cannot immediately request the same service again.
Saving a tool connection's agent audience updates its permission profile and installations in the same transaction. This includes GitHub credential admission and newly hired agents. Explicitly narrowing or clearing the audience removes the corresponding installations. An access request without exact tool names can review an indexed catalog of up to 500 tools; explicit model-selected tool lists remain limited to 20. Existing action blocks still apply, and write tools granted by an access card require approval.
New task setup must include the requesting agent, even when the human chooses a broader audience. The form and authorization phase save reject an omitted requester before provider sign-in. Save & exit persists the audience on the interaction without granting access; an incomplete draft can still have no agents selected. Resuming OAuth with hidden audience controls retains the saved audience rather than submitting form defaults; gateway draft setup restores the saved selection into its visible controls.
Agent installation changes made by finishing setup use the same agent-edit checks as direct installation changes. Explicit task audiences are checked when saved and again at OAuth completion. Additive completion retains existing installations, including agents using a separate narrow profile; retaining an installation does not add those agents to the app's broader permission profile.
## Verification
+5 -2
View File
@@ -24,8 +24,11 @@ to an internal issue tracker.
Inline task connection cards must use the same provider setup controller and
fields as Apps: URL guidance, authentication options, validation, and recovery.
Keep dialogs bounded to the form width on wide screens and scrollable on narrow
screens. A task request locks agent access to its requester and returns to the
card after completion; reusing an account must preserve its existing access.
screens. New tool connections default to all agents in both hosts, subject to
the human's connection-management authority. The human can select fewer agents.
Task OAuth saves that audience before sign-in and applies it atomically at
completion. AI authentication and inbox setup remain agent-scoped. Reusing an
account must preserve its existing access.
OAuth popups need a normal sign-in link fallback, and callback messages must be
verified against durable server state before accepting the request.
+2
View File
@@ -1513,6 +1513,8 @@ export type ConnectionIntentPhase = "requested" | "authorizing" | "needs_retry";
* authorization URLs are returned solely from addressed board endpoints.
*/
export interface ConnectionIntentPayload {
/** Human-selected audience for this OAuth draft; applied only at completion. */
setupAccess?: { connectionId: string; access: "all_agents" | { agentIds: string[] } };
/** Existing chat setup; the server derives the destination from these IDs. */
savedSetup?: { endpointId: string; connectionId: string; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null }; credentialEntry?: { previousGenerations: Record<string, string | null>; requireAll: boolean } };
version: 1;
@@ -57,12 +57,24 @@ describe("connection intent contracts", () => {
requestingAgentName: "CEO", phase: "requested" };
const payload = (tools: unknown[]) => ({ ...base, accessRequest: { connectionId: agentId, connectionName: "Account", tools } });
expect(connectionIntentPayloadSchema.safeParse(payload([tool])).success).toBe(true);
const catalog = Array.from({ length: 46 }, (_, i) => ({ ...tool,
catalogEntryId: `11111111-1111-4111-8111-${String(i).padStart(12, "0")}`, toolName: `tool_${i}` }));
expect(connectionIntentPayloadSchema.safeParse(payload(catalog)).success).toBe(true);
expect(connectionIntentPayloadSchema.safeParse(payload([])).success).toBe(false);
expect(connectionIntentPayloadSchema.safeParse(payload([tool, tool])).success).toBe(false);
expect(connectionIntentPayloadSchema.safeParse(payload([{ ...tool, versionHash: "" }])).success).toBe(false);
expect(connectionIntentPayloadSchema.safeParse(payload([{ ...tool, permission: "allow_all" }])).success).toBe(false);
});
it("binds a saved OAuth audience to a connection without accepting credentials", () => {
const base = { version: 1, serviceSlug: "github", serviceName: "GitHub", requestingAgentId: agentId,
requestingAgentName: "Onboarding", phase: "authorizing" };
const setupAccess = { connectionId: agentId, access: "all_agents" };
expect(connectionIntentPayloadSchema.parse({ ...base, setupAccess }).setupAccess).toEqual(setupAccess);
expect(connectionIntentPayloadSchema.safeParse({ ...base, setupAccess: { ...setupAccess, token: "secret" } }).success).toBe(false);
expect(connectionIntentPayloadSchema.safeParse({ ...base, setupAccess: { access: "all_agents" } }).success).toBe(false);
});
it("keeps generic interaction creation closed to the server-owned kind", () => {
expect(createIssueThreadInteractionSchema.safeParse({
kind: "connection_intent",
+5 -1
View File
@@ -1197,6 +1197,10 @@ const connectionIntentBrandAssetSchema = z
export const connectionIntentPayloadSchema = z
.object({
setupAccess: z.object({
connectionId: z.string().guid(),
access: z.union([z.literal("all_agents"), z.object({ agentIds: z.array(z.string().guid()).max(250) }).strict()]),
}).strict().optional(),
savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid(), action: z.enum(["enter_credentials", "rotate_credentials", "confirm_identity"]).optional(), credentialRotation: z.object({ previousId: z.string().guid().nullable() }).strict().optional(), credentialEntry: z.object({
previousGenerations: z.record(z.string().refine(field => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(field)), z.string().uuid().nullable()).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 5),
requireAll: z.boolean(),
@@ -1210,7 +1214,7 @@ export const connectionIntentPayloadSchema = z
toolName: z.string().trim().min(1).max(160),
versionHash: z.string().min(1).max(256),
permission: z.enum(["allowed", "ask_first"]),
}).strict()).min(1).max(20).refine(tools => new Set(tools.map(tool => tool.catalogEntryId)).size === tools.length, "Requested tools must be unique"),
}).strict()).min(1).max(500).refine(tools => new Set(tools.map(tool => tool.catalogEntryId)).size === tools.length, "Requested tools must be unique"),
}).strict().optional(),
upstreamService: z.object({ slug: z.string().min(1).max(120), name: z.string().min(1).max(160), selectionInteractionId: z.string().guid().optional() }).strict().optional(),
purpose: z.enum(["ai", "channel"]).optional(),
@@ -66,15 +66,16 @@ describeDatabase("connector access runtime synchronization", () => {
await service.replaceConnectionGrantMembers(connectionId, grant.id, []);
expect(await events()).toHaveLength(before + 4);
});
it.each(["uninstall", "permission save"] as const)("serializes %s with finalization or role changes before runtime locks", async change => {
it.each(["uninstall", "uninstall last", "permission save"] as const)("serializes %s with finalization or role changes before runtime locks", async change => {
let first: (client: Db) => Promise<unknown>;
let second: (client: Db) => Promise<unknown>;
let memberId: string | undefined;
if (change === "uninstall") {
if (change !== "permission save") {
await toolAccessService(db).putConnectionInstalls(connectionId, { installs: [{ targetType: "agent", targetId: agentId }] });
expect((await toolAccessService(db).getConnection(connectionId, companyId)).config?.mcpAgentAccessConfigured).toBe(true);
first = client => toolAccessService(client).putConnectionInstalls(connectionId, { installs: [] });
second = client => toolAccessService(client).finishGalleryAppConnection(companyId, connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" });
const uninstall = (client: Db) => toolAccessService(client).putConnectionInstalls(connectionId, { installs: [] });
const finish = (client: Db) => toolAccessService(client).finishGalleryAppConnection(companyId, connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" });
[first, second] = change === "uninstall" ? [uninstall, finish] : [finish, uninstall];
} else {
const [member] = await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "alice", status: "active", membershipRole: "member" }).returning();
memberId = member.id;
@@ -112,8 +113,12 @@ describeDatabase("connector access runtime synchronization", () => {
const results = await Promise.allSettled([initial, concurrent]);
for (const result of results) if (result.status === "rejected") throw result.reason;
expect(results.map(result => result.status)).toEqual(["fulfilled", "fulfilled"]);
if (change === "uninstall") {
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connectionId))).toHaveLength(0);
if (change !== "permission save") {
// The last serialized audience mutation wins: a later permission save
// installs its selected audience, while a later uninstall removes it.
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connectionId));
if (change === "uninstall") expect(installs).toEqual([expect.objectContaining({ targetType: "company", targetId: companyId })]);
else expect(installs).toEqual([]);
const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, companyId));
expect(bindings.some(binding => binding.metadata?.source === "tool_connection_install")).toBe(false);
} else {
@@ -532,12 +532,16 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" };
});
it("delivers Browser Use through the pinned native and CLI runtime gateway", async () => {
const f = await fixture();
await db.insert(toolConnectionInstalls).values({
// Finishing setup now saves runtime installation and access together.
// Exercise that installation instead of manufacturing another one.
const installs = await db.select().from(toolConnectionInstalls)
.where(eq(toolConnectionInstalls.connectionId, f.connection.connectionId));
expect(installs).toEqual([expect.objectContaining({
companyId: f.company.id,
connectionId: f.connection.connectionId,
targetType: "agent",
targetId: f.agent.id,
});
})]);
const snapshot = await resolveNativeRuntimeMcpSnapshot({
db,
agent: f.agent,
@@ -11,6 +11,7 @@ import {
issueComments,
companies,
companyMemberships,
principalPermissionGrants,
companySecretBindings,
companySecrets,
connectionGrantDelegations,
@@ -40,6 +41,7 @@ import { materializeNativeInteractionResponses } from "../services/native-runtim
import { toolAccessService } from "../services/tool-access.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { resolveManagedGitHubIdentitySelection } from "../services/git-credentials.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
getEmbeddedPostgresTestSupport,
@@ -912,15 +914,16 @@ describeEmbeddedPostgres("connectionIntentService", () => {
});
async function accessFixture() {
async function accessFixture(source = "composio") {
const [company] = await db.insert(companies).values({ name: "Grant fixture", issuePrefix: `G${randomUUID().slice(0, 7)}` }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Default agent", role: "general", adapterType: "process" }).returning();
const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent", role: "general", adapterType: "process" }).returning();
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "grant-user", membershipRole: "owner", status: "active" });
await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: "grant-user", permissionKey: "agents:configure" });
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Connect Circleback", status: "in_progress", assigneeAgentId: agent.id }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", responsibleUserId: "grant-user", contextSnapshot: { issueId: issue.id } }).returning();
const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Composio", type: "mcp_http", metadata: { sourceTemplateKey: "composio" } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: "Saved Composio", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", enabled: true, status: "active", healthStatus: "ok", config: { sourceTemplateKey: "composio" } }).returning();
const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: source, type: "mcp_http", metadata: { sourceTemplateKey: source } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: `Saved ${source}`, transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", enabled: true, status: "active", healthStatus: "ok", config: { sourceTemplateKey: source } }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active" });
await db.insert(toolConnectionInstalls).values({ companyId: company.id, connectionId: connection.id, targetType: "company", targetId: company.id });
const catalog = await db.insert(toolCatalogEntries).values([
@@ -931,6 +934,147 @@ describeEmbeddedPostgres("connectionIntentService", () => {
return { company, agent, otherAgent, issue, connection, catalog, service: connectionIntentService(db), claims: { ...claims, sub: agent.id, company_id: company.id, run_id: run.id, responsible_user_id: "grant-user" } };
}
it("keeps GitHub permission saves aligned with credential admission for existing and future agents", async () => {
const f = await accessFixture("github");
await db.update(connectionGrants).set({ kind: "user", subjectUserId: "grant-user" })
.where(eq(connectionGrants.connectionId, f.connection.id));
const access = toolAccessService(db);
const save = (audience: "all_agents" | { agentIds: string[] }) => access.finishGalleryAppConnection(f.company.id, f.connection.id, {
enabledCatalogEntryIds: f.catalog.map(tool => tool.id), askFirstCatalogEntryIds: [], access: audience,
}, { actorType: "user", actorId: "grant-user" });
const select = (agentId: string) => resolveManagedGitHubIdentitySelection(db, f.company.id, {
agentId, responsibleUserId: "grant-user",
});
await save({ agentIds: [f.agent.id] });
await expect(select(f.otherAgent.id)).resolves.toMatchObject({ configured: false });
await save("all_agents");
await expect(select(f.otherAgent.id)).resolves.toMatchObject({ configured: true, grant: { connectionId: f.connection.id } });
const [newHire] = await db.insert(agents).values({ companyId: f.company.id, name: "New engineer", role: "engineer", adapterType: "process" }).returning();
await expect(select(newHire.id)).resolves.toMatchObject({ configured: true, grant: { connectionId: f.connection.id } });
await expect(f.service.usableConnectionForAgent({ companyId: f.company.id, agentId: newHire.id,
responsibleUserId: "grant-user", serviceSlug: "github" })).resolves.toMatchObject({ id: f.connection.id });
await save({ agentIds: [f.agent.id] });
await expect(select(newHire.id)).resolves.toMatchObject({ configured: false });
await save({ agentIds: [] });
await expect(select(f.agent.id)).resolves.toMatchObject({ configured: false });
expect(await access.listConnectionInstalls(f.connection.id, f.company.id)).toEqual([]);
expect((await access.getConnection(f.connection.id, f.company.id))?.config?.mcpAgentAccessConfigured).toBe(true);
});
it("keeps installations with a separate narrow profile when account completion is additive", async () => {
const f = await accessFixture("github");
const access = toolAccessService(db);
await access.finishGalleryAppConnection(f.company.id, f.connection.id, {
enabledCatalogEntryIds: f.catalog.map(tool => tool.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
});
const [appProfile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${f.connection.id}`));
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, appProfile.id));
const [narrow] = await db.insert(toolProfiles).values({ companyId: f.company.id, profileKey: randomUUID(), name: "Narrow existing access", defaultAction: "deny" }).returning();
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: narrow.id, selectorType: "catalog_entry", catalogEntryId: f.catalog[0].id, effect: "include" });
await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: narrow.id, targetType: "agent", targetId: f.agent.id });
await access.finishGalleryAppConnection(f.company.id, f.connection.id, {
enabledCatalogEntryIds: f.catalog.map(tool => tool.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.otherAgent.id] }, preserveExistingAccess: true,
});
expect((await access.listConnectionInstalls(f.connection.id, f.company.id)).map(install => install.targetId).sort()).toEqual([f.agent.id, f.otherAgent.id].sort());
const effective = await access.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id);
expect(effective.installedConnections.map(connection => connection.id)).toContain(f.connection.id);
expect(effective.allowedTools.map(tool => tool.id)).toEqual([f.catalog[0].id]);
expect((await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, appProfile.id))).map(binding => binding.targetId)).toEqual([f.otherAgent.id]);
});
it("creates and resolves an access card for a 46-tool connector without model-supplied tool names", async () => {
const f = await accessFixture("notion");
await db.insert(toolCatalogEntries).values(Array.from({ length: 43 }, (_, i) => ({
companyId: f.company.id, connectionId: f.connection.id, name: `read_${i}`, toolName: `read_${i}`,
entryKind: "tool" as const, status: "active" as const, riskLevel: "read" as const, versionHash: "v1",
})));
const request = await f.service.request(f.claims, "notion");
const loaded = await f.service.loadIntent(request.interactionId!);
expect(loaded.interaction.payload.accessRequest?.tools).toHaveLength(46);
await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true }))
.resolves.toMatchObject({ status: "accepted" });
expect((await f.service.search(f.claims, "notion")).results[0]).toMatchObject({ state: "ready", connectionId: f.connection.id });
expect(await f.service.usableConnectionForAgent({ companyId: f.company.id, agentId: f.otherAgent.id,
responsibleUserId: "grant-user", serviceSlug: "notion" })).toBeNull();
});
it("saves a human audience before OAuth without granting access and rechecks sharing authority", async () => {
const f = await accessFixture("notion");
const interaction = await issueThreadInteractionService(db).createConnectionIntent(f.issue, {
payload: { version: 1, serviceSlug: "notion", serviceName: "Notion", requestingAgentId: f.agent.id,
requestingAgentName: f.agent.name, phase: "requested" },
sourceRunId: f.claims.run_id, addresseeUserId: "grant-user", idempotencyKey: randomUUID(),
});
const setupAccess = { connectionId: f.connection.id, access: "all_agents" as const };
await expect(f.service.updatePhase(interaction.id, "authorizing", "grant-user", { setupAccess }))
.rejects.toMatchObject({ status: 403 });
await expect(f.service.updatePhase(interaction.id, "authorizing", "another-user", { setupAccess, canManageOrganizationGrant: true }))
.rejects.toMatchObject({ status: 403 });
await expect(f.service.updatePhase(interaction.id, "authorizing", "grant-user", {
setupAccess: { connectionId: f.connection.id, access: { agentIds: [f.otherAgent.id] } },
canManageOrganizationGrant: true,
})).rejects.toThrow("Select the requesting agent");
expect((await f.service.loadIntent(interaction.id)).interaction.payload.setupAccess).toBeUndefined();
const incompleteDraft = { connectionId: f.connection.id, access: { agentIds: [] } };
await f.service.updatePhase(interaction.id, "requested", "grant-user", { setupAccess: incompleteDraft });
expect((await f.service.loadIntent(interaction.id)).interaction.payload.setupAccess).toEqual(incompleteDraft);
await expect(f.service.updatePhase(interaction.id, "authorizing", "grant-user", { setupAccess: incompleteDraft }))
.rejects.toThrow("Select the requesting agent");
const restrictedSetup = { connectionId: f.connection.id, access: { agentIds: [f.agent.id] } };
await f.service.updatePhase(interaction.id, "authorizing", "grant-user", { setupAccess: restrictedSetup });
await f.service.updatePhase(interaction.id, "needs_retry", "grant-user");
expect((await f.service.loadIntent(interaction.id)).interaction.payload.setupAccess).toEqual(restrictedSetup);
await f.service.updatePhase(interaction.id, "authorizing", "grant-user", { setupAccess, canManageOrganizationGrant: true });
await f.service.updatePhase(interaction.id, "needs_retry", "grant-user");
expect((await f.service.loadIntent(interaction.id)).interaction.payload.setupAccess).toEqual(setupAccess);
expect(await f.service.usableConnectionForAgent({ companyId: f.company.id, agentId: f.agent.id,
responsibleUserId: "grant-user", serviceSlug: "notion" })).toBeNull();
await expect(f.service.complete(interaction.id, f.connection.id, "grant-user", { oauthCompletion: true }))
.rejects.toMatchObject({ status: 403 });
});
it("checks agent edit scope when saving task audiences and again at OAuth completion", async () => {
const f = await accessFixture("notion");
await db.update(companyMemberships).set({ membershipRole: "member" }).where(eq(companyMemberships.companyId, f.company.id));
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, f.company.id));
const interaction = await issueThreadInteractionService(db).createConnectionIntent(f.issue, {
payload: { version: 1, serviceSlug: "notion", serviceName: "Notion", requestingAgentId: f.agent.id, requestingAgentName: f.agent.name, phase: "requested" },
sourceRunId: f.claims.run_id, addresseeUserId: "grant-user", idempotencyKey: randomUUID(),
});
const setupAccess = { connectionId: f.connection.id, access: { agentIds: [f.agent.id] } };
await expect(f.service.updatePhase(interaction.id, "authorizing", "grant-user", { setupAccess })).rejects.toMatchObject({ status: 403 });
await db.insert(principalPermissionGrants).values({ companyId: f.company.id, principalType: "user", principalId: "grant-user", permissionKey: "agents:configure", scope: { targetAgentIds: [f.agent.id] } });
await f.service.updatePhase(interaction.id, "authorizing", "grant-user", { setupAccess });
await expect(f.service.updatePhase(interaction.id, "authorizing", "grant-user", {
setupAccess: { connectionId: f.connection.id, access: { agentIds: [f.agent.id, f.otherAgent.id] } }, canManageOrganizationGrant: true,
})).rejects.toMatchObject({ status: 403 });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, f.company.id));
await expect(f.service.complete(interaction.id, f.connection.id, "grant-user", { oauthCompletion: true, canManageOrganizationGrant: true }))
.rejects.toMatchObject({ status: 403 });
expect((await f.service.loadIntent(interaction.id)).interaction.status).toBe("pending");
});
it("applies the selected audience for an organization OAuth grant without requiring personal credentials", async () => {
const f = await accessFixture("notion");
await db.update(toolConnections).set({ authKind: "oauth", credentialSecretRefs: [
{ secretId: randomUUID(), configPath: "oauth.access_token", versionSelector: "latest" },
] }).where(eq(toolConnections.id, f.connection.id));
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, f.connection.id));
const interaction = await issueThreadInteractionService(db).createConnectionIntent(f.issue, {
payload: { version: 1, serviceSlug: "notion", serviceName: "Notion", requestingAgentId: f.agent.id,
requestingAgentName: f.agent.name, phase: "requested" },
sourceRunId: f.claims.run_id, addresseeUserId: "grant-user", idempotencyKey: randomUUID(),
});
await f.service.updatePhase(interaction.id, "authorizing", "grant-user", {
setupAccess: { connectionId: f.connection.id, access: "all_agents" }, canManageOrganizationGrant: true,
});
await expect(f.service.complete(interaction.id, f.connection.id, "grant-user", {
oauthCompletion: true, canManageOrganizationGrant: true,
})).resolves.toMatchObject({ status: "accepted" });
await expect(f.service.usableConnectionForAgent({ companyId: f.company.id, agentId: f.otherAgent.id,
responsibleUserId: "grant-user", serviceSlug: "notion" })).resolves.toMatchObject({ id: f.connection.id });
});
it("creates a scoped access card and atomically accepts repeated clicks without granting other agents", async () => {
const f = await accessFixture();
const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] });
@@ -1001,10 +1145,9 @@ describeEmbeddedPostgres("connectionIntentService", () => {
await expect(f.service.request({ ...f.claims, company_id: randomUUID() }, "composio", { connectionId: f.connection.id })).rejects.toThrow();
});
it("lets an agent hired before a GitHub grant request the access the grant left out", async () => {
// Mirrors staging BUG-09: GitHub was connected through the CEO's request, so the
// responsible user's personal grant was delegated and installed for the CEO only.
// A Frontend Engineer hired earlier has neither, and GitHub's catalog exceeds 20 tools.
it("lets an agent outside a GitHub grant request scoped access beyond the access-card catalog bound", async () => {
// A personal grant was delegated and installed for the CEO only. An engineer
// outside it still needs scoped discovery when a catalog exceeds 500 tools.
const [company] = await db.insert(companies).values({ name: "GitHub grant fixture", issuePrefix: `H${randomUUID().slice(0, 7)}` }).returning();
const [ceo] = await db.insert(agents).values({ companyId: company.id, name: "CEO", role: "ceo", adapterType: "process" }).returning();
const [engineer] = await db.insert(agents).values({ companyId: company.id, name: "Frontend Engineer", role: "engineer", adapterType: "process" }).returning();
@@ -1016,7 +1159,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
const [grant] = await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "github-owner", status: "active" }).returning();
await db.insert(connectionGrantDelegations).values({ companyId: company.id, grantId: grant.id, agentId: ceo.id, createdByUserId: "github-owner" });
await db.insert(toolConnectionInstalls).values({ companyId: company.id, connectionId: connection.id, targetType: "agent", targetId: ceo.id });
const readTools = ["get_file_contents", "get_me", "list_commits", ...Array.from({ length: 28 }, (_, index) => `read_tool_${index}`)];
const readTools = ["get_file_contents", "get_me", "list_commits", ...Array.from({ length: 483 }, (_, index) => `read_tool_${index}`)];
const writeTools = ["create_or_update_file", "create_repository", "push_files", ...Array.from({ length: 12 }, (_, index) => `write_tool_${index}`)];
await db.insert(toolCatalogEntries).values([
...readTools.map(toolName => ({ toolName, riskLevel: "read" as const })),
@@ -1033,7 +1176,8 @@ describeEmbeddedPostgres("connectionIntentService", () => {
const refusal = await service.request(engineerClaims, "github").then(() => null, (error: Error) => error);
expect(refusal).toMatchObject({ status: 422 });
expect(refusal?.message).toContain(connection.id);
for (const toolName of ["create_repository", "push_files", "write_tool_11", "read_tool_27"]) expect(refusal?.message).toContain(toolName);
for (const toolName of ["create_repository", "push_files", "read_tool_1"]) expect(refusal?.message).toContain(toolName);
expect(refusal?.message).toContain("first 100 of 501");
expect(refusal?.message).toContain("No access was granted");
const scoped = await service.request(engineerClaims, "github", { connectionId: connection.id, toolNames: ["get_me", "create_repository", "push_files"] });
@@ -492,6 +492,38 @@ describe("connection management with production default agents", () => {
} finally { unregisterServerAdapter("codex_local"); }
});
it.each(["add", "remove"] as const)("requires a connection creator to edit agent targets when finish will %s installations", async (change) => {
const f = await server.fixture({ connectionScenario: "fresh" });
const userId = f.responsibleUserId!;
const cookie = await server.humanSession(userId);
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Creator install authority", type: "mcp_http" }).returning();
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
uid: randomUUID(), name: "Creator-owned connection", transport: "mcp_remote", authKind: "none", status: "active", createdByUserId: userId }).returning();
if (change === "remove") await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: f.agentId });
const before = await server.db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
const finish = async () => fetch(`${server.apiUrl}/api/companies/${f.companyId}/tools/apps/${connection.id}/finish`, {
method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
body: JSON.stringify({ enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: change === "add" ? [f.agentId] : [] } }),
});
const denied = await finish();
expect(await denied.json()).toMatchObject({ error: expect.any(String) });
expect(denied.status).toBe(403);
expect(await server.db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id))).toEqual(before);
expect(await server.db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connection.id}`))).toEqual([]);
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId,
permissionKey: "agents:configure", scope: { targetAgentIds: [f.agentId] } });
const allowed = await finish();
expect(allowed.status, JSON.stringify(await allowed.json())).toBe(200);
expect(await server.db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id))).toHaveLength(change === "add" ? 1 : 0);
const [peer] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Outside edit scope", adapterType: "process" }).returning();
const outOfScope = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/tools/apps/${connection.id}/finish`, {
method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" },
body: JSON.stringify({ enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [peer.id] } }),
});
expect(outOfScope.status).toBe(403);
expect(await server.db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id))).toHaveLength(change === "add" ? 1 : 0);
});
it("checks finish access, all-agent sharing, and retained targets against both management grants", async () => {
const f = await fixture();
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Finish authority", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
@@ -90,6 +90,7 @@ import { createOAuthClientRegistration } from "../services/tool-access/oauth/cli
import { createPersonalConnectionIdentity } from "../services/tool-access/connections/personal-identity.js";
import { createConnectionSetupPolicy } from "../services/tool-access/connections/setup-policy.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { secretService } from "../services/secrets.js";
import {
canonicalToolArguments,
@@ -10820,8 +10821,8 @@ describeEmbeddedPostgres("tool access service", () => {
).toBe(versionCountBeforeAccessRevocation);
});
it.each(["page", "task"] as const)(
"activates and discovers actions for a fresh personal OAuth callback from %s without widening task access",
it.each(["page", "task", "task-all-agents"] as const)(
"activates personal OAuth from %s with the selected audience only after finalization",
async (host) => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv(
@@ -10835,7 +10836,7 @@ describeEmbeddedPostgres("tool access service", () => {
const otherAgent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
const interaction =
host === "task"
host !== "page"
? (
await db
.insert(issueThreadInteractions)
@@ -10972,12 +10973,12 @@ describeEmbeddedPostgres("tool access service", () => {
});
for (const entry of [searchMessagesEntry, sendMessageEntry]) {
await expect(decide(entry)).resolves.toMatchObject(
host === "task"
host !== "page"
? { decision: "deny" }
: { decision: "allow", reasonCode: "allow_profile" },
);
}
if (host === "task") {
if (host !== "page") {
await expect(
db
.select()
@@ -11015,14 +11016,26 @@ describeEmbeddedPostgres("tool access service", () => {
personalGrant.credentialSecretRefs.map((ref) => ref.configPath).sort(),
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
const finished = await service.finalizeOAuthAccess(
if (host === "task-all-agents") {
const intents = connectionIntentService(db);
await intents.updatePhase(interaction!.id, "authorizing", userId, {
setupAccess: { connectionId: connected.connectionId, access: "all_agents" },
canManageOrganizationGrant: true,
});
await expect(intents.complete(interaction!.id, connected.connectionId, userId, { canManageOrganizationGrant: true, oauthCompletion: true }))
.resolves.toMatchObject({ status: "accepted" });
}
const finished = host === "task-all-agents" ? {
profileEntries: await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, callbackProfile!.id)),
profileBindings: await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, callbackProfile!.id)),
} : await service.finalizeOAuthAccess(
company.id,
connected.connectionId,
{
grantKind: "user",
},
{ actorType: "user", actorId: userId },
host === "task" ? agent.id : undefined,
host !== "page" ? agent.id : undefined,
);
expect(finished.profileEntries).toHaveLength(2);
expect(finished.profileBindings).toEqual([
@@ -11043,6 +11056,11 @@ describeEmbeddedPostgres("tool access service", () => {
),
),
).resolves.toHaveLength(host === "task" ? 0 : 1);
if (host === "task-all-agents") {
await expect(decide(searchMessagesEntry, otherAgent.id)).resolves.toMatchObject({ decision: "allow" });
expect((await service.getEffectiveToolAssignmentsForAgent(company.id, otherAgent.id)).installedConnections)
.toEqual(expect.arrayContaining([expect.objectContaining({ id: connected.connectionId })]));
}
if (host === "task") {
await expect(decide(searchMessagesEntry)).resolves.toMatchObject({
decision: "allow",
@@ -11138,7 +11156,7 @@ describeEmbeddedPostgres("tool access service", () => {
{
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
actor: { actorType: "user", actorId: userId },
interactionId: interaction?.id,
interactionId: host === "task-all-agents" ? undefined : interaction?.id,
},
);
await expect(
+4 -1
View File
@@ -5,6 +5,7 @@ import {
CONNECTION_REQUEST_TOOL_DESCRIPTION,
CONNECTIONS_SEARCH_TOOL_DESCRIPTION,
completeConnectionIntentSchema,
connectionIntentPayloadSchema,
connectionRequestInputSchema,
connectionsSearchInputSchema,
declineConnectionIntentSchema,
@@ -187,7 +188,7 @@ export function connectionIntentBoardRoutes(db: Db, heartbeat: Heartbeat) {
});
router.post("/connection-intents/:interactionId/phase", async (req, res) => {
const { userId } = await addressedIntent(req);
const { loaded, userId } = await addressedIntent(req);
const phase = req.body?.phase;
if (phase !== "requested" && phase !== "authorizing" && phase !== "needs_retry") {
res.status(422).json({ error: "phase must be requested, authorizing, or needs_retry" });
@@ -195,6 +196,8 @@ export function connectionIntentBoardRoutes(db: Db, heartbeat: Heartbeat) {
}
res.json(await service.updatePhase(req.params.interactionId as string, phase, userId, {
bypassCurrentMembershipCheck: bypassCurrentMembershipCheck(req),
canManageOrganizationGrant: await canManageCompanyConnections(req, loaded.issue.companyId),
setupAccess: connectionIntentPayloadSchema.shape.setupAccess.parse(req.body?.setupAccess),
}));
});
+2
View File
@@ -309,6 +309,7 @@ import {
toolPolicyTestRequestSchema,
createToolMcpGatewaySchema,
completeConnectionIntentSchema,
connectionIntentPayloadSchema,
connectionRequestInputSchema,
connectionsSearchInputSchema,
declineConnectionIntentSchema,
@@ -11271,6 +11272,7 @@ registerCurrentRoute({
body: z
.object({
phase: z.enum(["requested", "authorizing", "needs_retry"]),
setupAccess: connectionIntentPayloadSchema.shape.setupAccess,
})
.strict(),
responses: {
+3 -17
View File
@@ -59,7 +59,7 @@ import {
updateToolProfileWithEntriesSchema,
} from "@paperclipai/shared";
import { filterConnectionManagementRows, filterConnectionManagementPolicies, connectionManagementGuard, requiredConnectionUserAction, connectionRequestActor, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { connectionManagementService } from "../services/connection-management.js";
import { assertAgentConnectionInstallAccess, connectionManagementService } from "../services/connection-management.js";
import { validate } from "../middleware/validate.js";
import { getActorInfo, assertBoard, assertBoardOrAgent, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
@@ -287,6 +287,7 @@ export function toolAccessRoutes(
if (loaded.interaction.status !== "pending") return loaded.interaction;
const interaction = input.outcome === "connected" && input.connectionId
? await connectionIntents.complete(input.interactionId, input.connectionId, input.userId, {
oauthCompletion: true,
canManageOrganizationGrant: input.canManageOrganizationGrant,
bypassCurrentMembershipCheck: input.bypassCurrentMembershipCheck,
})
@@ -1976,22 +1977,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
.filter((install) => install.targetType === "agent")
.map((install) => install.targetId),
)];
for (const agentId of changedAgentIds) {
const [agent] = await db
.select({ id: agents.id, companyId: agents.companyId })
.from(agents)
.where(and(eq(agents.id, agentId), eq(agents.companyId, connection.companyId)))
.limit(1);
if (!agent) throw forbidden("The target agent is not available in this company");
const decision = await access.decide({
actor: req.actor,
action: "agent_config:update",
resource: { type: "agent", companyId: connection.companyId, agentId: agent.id },
});
if (!decision.allowed) {
throw forbidden(`You cannot edit agent ${agent.id}, so you cannot change its connection installs`);
}
}
await assertAgentConnectionInstallAccess(db, req.actor, connection.companyId, changedAgentIds);
}
const snapshot = await svc.putConnectionInstalls(connection.id, req.body, connectionRequestActor(req));
await logActivity(db, {
+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,
+2 -1
View File
@@ -12,10 +12,11 @@ export const connectionIntentsApi = {
setPhase: (
interactionId: string,
phase: ConnectionIntentInteraction["payload"]["phase"],
setupAccess?: ConnectionIntentInteraction["payload"]["setupAccess"],
) =>
api.post<ConnectionIntentInteraction>(
`/connection-intents/${interactionId}/phase`,
{ phase },
{ phase, ...(setupAccess ? { setupAccess } : {}) },
),
complete: (interactionId: string, connectionId: string) =>
api.post<ConnectionIntentInteraction>(
@@ -219,7 +219,8 @@ describe("IssueThreadInteractionCard", () => {
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
expect(labels).toContain("Connect");
expect(labels).toContain("Not now");
expect(host.textContent).toContain("Access is added only for this agent");
expect(host.textContent).toContain("New connections default to all agents");
expect(host.textContent).toContain("Reusing an account adds access for this agent");
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Connect",
);
@@ -83,6 +83,7 @@ vi.mock("@/components/ai-connections/AiProviderSetup", () => ({
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
requiredAgentId?: string;
existingConnections?: ToolConnection[];
onUseExisting?: (id: string) => Promise<void>;
onComplete?: (completion: { connectionId: string } | { resolvedByCallback: true }) => void;
@@ -91,6 +92,7 @@ vi.mock("./ConnectionSetupFlow", () => ({
}) => (
<div data-testid="shared-connection-setup">
<span data-testid="requested-agent">{props.requestedAgentId}</span>
<span data-testid="required-agent">{props.requiredAgentId}</span>
<span data-testid="existing-count">
{props.existingConnections?.length ?? 0}
</span>
@@ -318,7 +320,7 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
expect(completeMock).not.toHaveBeenCalled();
});
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
it("passes existing choices and requires the requester without locking tool setup to that agent", async () => {
let resolveSetup!: (value: unknown) => void;
setupOptionsMock.mockReturnValue(
new Promise((resolve) => {
@@ -343,7 +345,10 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
expect(
document.querySelector('[data-testid="requested-agent"]')?.textContent,
).toBe("agent-requesting");
).toBe("");
expect(document.querySelector('[data-testid="required-agent"]')?.textContent).toBe("agent-requesting");
expect(document.body.textContent).toContain("New connections default to all agents");
expect(document.body.textContent).not.toContain("Access is added only for this agent");
expect(
document.querySelector('[data-testid="existing-count"]')?.textContent,
).toBe("2");
@@ -238,7 +238,11 @@ export function ConnectionIntentInteractionBody({
upstreamServiceName: interaction.payload.upstreamService?.name,
serviceSlug: interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug,
configuredConnection: interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined,
requestedAgentId: setupQuery.data.requestedAgentId,
// Tool setup uses the same audience defaults and controls as Apps.
// Runtime authentication and inboxes still belong to the requesting agent.
requestedAgentId: interaction.payload.purpose ? setupQuery.data.requestedAgentId : undefined,
requiredAgentId: interaction.payload.purpose ? undefined : setupQuery.data.requestedAgentId,
setupAccess: setupQuery.data.interaction?.payload.setupAccess,
aiConnection: setupQuery.data.aiConnection,
interactionId: interaction.id,
existingConnections: setupQuery.data.existingConnections,
@@ -481,7 +485,7 @@ export function ConnectionIntentInteractionBody({
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
: isChatHandoff ? "Complete the human sign-in step in the saved setup. Your task continues when the connection is ready."
: isEmail ? "Connect AgentMail to create an email address for this agent."
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
: "New connections default to all agents; you can choose fewer during setup. Reusing an account adds access for this agent."}
</p>
</div>
</div>
@@ -568,6 +568,9 @@ export interface ConnectionSetupFlowProps {
host?: "page" | "dialog";
serviceSlug?: string;
requestedAgentId?: string;
/** Task requester must be included without restricting the audience to it. */
requiredAgentId?: string;
setupAccess?: import("@paperclipai/shared").ConnectionIntentInteraction["payload"]["setupAccess"];
interactionId?: string;
forceNewConnection?: boolean;
existingConnections?: ConnectionIntentSetupConnection[];
@@ -645,6 +648,7 @@ function StandardConnectionSetupFlow({
host = "page",
serviceSlug,
requestedAgentId,
requiredAgentId,
interactionId,
forceNewConnection = false,
existingConnections = [],
@@ -1384,6 +1388,17 @@ function StandardConnectionSetupFlow({
// not identity: an organization Notion connection must stay organization-
// scoped, while a personal one must put its token back on that user grant.
mutationFn: async (connection: ToolConnection) => {
// Resumed setup hides audience controls and promises to retain access.
// Leave the durable selection untouched instead of saving form defaults.
if (connectionIntentId && !aiConnection && !resumeConnectionId && !reconnectConnection && !resumableOAuthConnection) {
if (requiredAgentId && installChoice !== "all" && !installAgentIds.has(requiredAgentId)) {
throw new Error("Include the requesting agent or choose all agents to continue this task.");
}
await connectionIntentsApi.setPhase(connectionIntentId, "authorizing", {
connectionId: connection.id,
access: installChoice === "all" ? "all_agents" : { agentIds: [...installAgentIds] },
});
}
// Persist before navigation, including retries that reuse an existing OAuth draft.
if (instructionValue && JSON.stringify(instructionValue) !== JSON.stringify(connection.agentInstructions)) {
await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue });
@@ -1439,7 +1454,7 @@ function StandardConnectionSetupFlow({
*/
const applyAccessInstalls = async (connectionId: string) => {
// The task completion endpoint adds the requester atomically with resolution.
if (requestedAgentId) return;
if (connectionIntentId) return;
const dedicatedIdentity = (fixedGrantKind ?? grantKind) === "agent";
const installState = !dedicatedIdentity && installChoice === "all"
? { onAll: true, agentIds: new Set<string>() }
@@ -1451,6 +1466,10 @@ function StandardConnectionSetupFlow({
const effectiveGrantKind = fixedGrantKind ?? grantKind;
const connectMutation = useMutation({
mutationFn: async (entryOverride?: AppDefinition) => {
if (requiredAgentId && !resumeConnectionId && !reconnectConnectionId
&& installChoice !== "all" && !installAgentIds.has(requiredAgentId)) {
throw new Error("Include the requesting agent or choose all agents to continue this task.");
}
const connectEntry = entryOverride ?? entry;
let result: ConnectToolAppResult;
if (connectEntry) {
@@ -1919,6 +1938,9 @@ function StandardConnectionSetupFlow({
connected,
(catalogEntryId) => Boolean(enabledMap[catalogEntryId]),
);
if (requiredAgentId && installChoice !== "all" && !installAgentIds.has(requiredAgentId)) {
throw new Error("Include the requesting agent or choose all agents to continue this task.");
}
// The Access step asks one question about agent reach, so profile access
// and installs are committed to the same target set instead of drifting
// apart behind two separate wizard screens.
@@ -2212,6 +2234,7 @@ function StandardConnectionSetupFlow({
?? "Only someone who can configure this connection can give every agent access."
: null,
requestedAgentId ? "This task grants access only to the agent that asked for it." : null,
requiredAgentId ? "Include the requesting agent when choosing specific agents." : null,
].filter((reason): reason is string => Boolean(reason))}
authKind={accessStepAuthKind}
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { ToolConnection } from "@paperclipai/shared";
import type { ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
import { ApiError } from "@/api/client";
import { toolsApi } from "@/api/tools";
import { RemoteMcpProductionSetup } from "./RemoteMcpProductionSetup";
@@ -33,10 +34,10 @@ let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
function render() {
function render(props: Partial<ConnectionSetupFlowProps> & { connection?: ToolConnection } = {}) {
act(() => root.render(
<QueryClientProvider client={client}>
<RemoteMcpProductionSetup providerId="composio" connection={connection} />
<RemoteMcpProductionSetup providerId="composio" connection={connection} {...props} />
</QueryClientProvider>,
));
}
@@ -48,6 +49,15 @@ beforeEach(() => {
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
it("restores a task's saved draft audience before installations exist", async () => {
vi.mocked(toolsApi.getConnectionInstalls).mockResolvedValue({ installs: [] } as never);
render({ connection: { ...connection, status: "draft" }, interactionId: "intent", requiredAgentId: "agent-1",
setupAccess: { connectionId: connection.id, access: { agentIds: ["agent-1"] } } });
await vi.waitFor(() => expect(container.querySelector('[data-testid="remote-mcp-setup"]')?.textContent).toBe("agent-1"));
await act(async () => { await client.invalidateQueries(); });
expect(container.querySelector('[data-testid="remote-mcp-setup"]')?.textContent).toBe("agent-1");
});
afterEach(() => {
act(() => root.unmount());
client.clear();
@@ -4,7 +4,7 @@ import { ConnectionInstructionsEditor } from "../ConnectionInstructions";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { isAppAggregator, aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps";
import { REMOTE_MCP_CONNECTOR_METHODS, defaultConnectionAgentInstructions, getConnectableAppDefinition, type ToolConnection } from "@paperclipai/shared";
import { REMOTE_MCP_CONNECTOR_METHODS, defaultConnectionAgentInstructions, getConnectableAppDefinition, type ConnectionIntentSetupOptions, type ToolConnection } from "@paperclipai/shared";
import { askFirstCatalogEntryIdsFor } from "../connection-defaults";
import { RemoteMcpAccountChoice } from "./RemoteMcpAccountChoice";
import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
@@ -32,7 +32,7 @@ function readAccessDraft(key: string): Partial<RemoteMcpSetupState> {
}
export function RemoteMcpProductionSetup({ providerId, connection, host = "page", interactionId,
upstreamServiceName, requestedAgentId, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange,
upstreamServiceName, requestedAgentId, requiredAgentId, setupAccess, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange,
}: ConnectionSetupFlowProps & { providerId: RemoteMcpProviderId; connection?: ToolConnection }) {
const provider = remoteMcpProviders[providerId];
const template = getConnectableAppDefinition(providerId)?.agentInstructions;
@@ -65,6 +65,9 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
const savedConnection = useRef(connection);
const busy = useRef(false);
const authorizationUrl = useRef<string | undefined>(undefined);
const savedAudience = connection?.status === "draft" && setupAccess?.connectionId === connection.id
? setupAccess.access : undefined;
const hydratedAudience = useRef<string | null>(null);
const [state, setState] = useState<RemoteMcpSetupState>(() => ({
// PAP-659 C0: there is no Access step on the way in. The resolved default
// is stated on the connect screen and changed in its Advanced disclosure;
@@ -77,6 +80,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
token: "", headers: [], connectStatus: oauthOutcome === "denied" ? "cancelled" : oauthOutcome === "failed" ? "oauth_failed" : "idle", connected: false,
identity: null, allAgents: true, agentIds: [], permissions: {}, tools: [], notice: connection?.authKind === "api_key" ? "Saved credentials are retained when these fields are left blank. Enter a replacement only to change them." : null, refreshing: false,
...(!connection ? readAccessDraft(accessDraftKey) : {}),
...(savedAudience ? { allAgents: savedAudience === "all_agents", agentIds: savedAudience === "all_agents" ? [] : savedAudience.agentIds } : {}),
...(requestedAgentId ? { allAgents: false, agentIds: [requestedAgentId] } : {}),
}));
const installs = useQuery({ queryKey: queryKeys.tools.connectionInstalls(connection?.id ?? "__new__"), queryFn: () => toolsApi.getConnectionInstalls(connection!.id), enabled: !!connection });
@@ -85,8 +89,18 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId,
});
useEffect(() => {
if (installs.data && !requestedAgentId) setState((s) => ({ ...s, allAgents: installs.data.installs.some((i) => i.targetType === "company"), agentIds: installs.data.installs.filter((i) => i.targetType === "agent").map((i) => i.targetId) }));
}, [installs.data, requestedAgentId]);
const savedInstalls = installs.data?.installs;
if (!savedInstalls || requestedAgentId) return;
if (savedAudience) {
const key = JSON.stringify(savedAudience);
if (hydratedAudience.current === key) return;
hydratedAudience.current = key;
}
setState((s) => ({ ...s,
allAgents: savedAudience ? savedAudience === "all_agents" : savedInstalls.some((i) => i.targetType === "company"),
agentIds: savedAudience ? savedAudience === "all_agents" ? [] : savedAudience.agentIds : savedInstalls.filter((i) => i.targetType === "agent").map((i) => i.targetId),
}));
}, [installs.data, requestedAgentId, savedAudience]);
useEffect(() => {
if (connection) return;
try { sessionStorage.setItem(accessDraftKey, JSON.stringify({ grantKind: state.grantKind, allAgents: state.allAgents, agentIds: state.agentIds })); } catch { /* Storage can be disabled. */ }
@@ -179,6 +193,10 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
}, [state.connectStatus, interactionId]);
const submit = async (saveDraft = false) => {
if (busy.current || !selectedCompanyId || !instructionsValid || (connection && !installs.data)) return;
if (!saveDraft && (!connection || connection.status === "draft") && requiredAgentId && !state.allAgents && !state.agentIds.includes(requiredAgentId)) {
edit({ notice: "Include the requesting agent or choose all agents to continue this task." });
return;
}
try {
const url = new URL(state.url.trim());
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
@@ -227,11 +245,19 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
try { sessionStorage.setItem(intentDraftKey, result.connectionId); } catch { /* Storage may be disabled. */ }
}
// Persist the Access step before leaving for OAuth. Reconnect retains its existing installs.
if (!requestedAgentId && (!prior || prior.status === "draft")) {
if (!interactionId && !requestedAgentId && (!prior || prior.status === "draft")) {
await toolsApi.putConnectionInstalls(result.connectionId, state.allAgents
? [{ targetType: "company", targetId: selectedCompanyId }]
: state.agentIds.map((targetId) => ({ targetType: "agent", targetId })));
}
if (interactionId && (!connection || connection.status === "draft") && (saveDraft || result.auth?.kind === "oauth")) {
const interaction = await connectionIntentsApi.setPhase(interactionId, saveDraft ? "requested" : "authorizing", {
connectionId: result.connectionId,
access: state.allAgents ? "all_agents" : { agentIds: state.agentIds },
});
queries.setQueryData<ConnectionIntentSetupOptions>(["connection-intent", interactionId, "setup-options"],
current => current ? { ...current, interaction } : current);
}
if (saveDraft) { if (onCancel) onCancel(); else navigate("/apps"); return; }
if (result.auth?.kind === "oauth") {
onPhaseChange?.("authorizing");
+2 -2
View File
@@ -715,7 +715,7 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
appName={appName}
agents={agents}
access={access}
install={connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? installStateFrom([]) : install}
install={connection.connectionPurpose === "ai" || connection.connectionPurpose === "tool" ? installStateFrom([]) : install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
@@ -728,7 +728,7 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls."
: undefined
}
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })}
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || connection.connectionPurpose === "tool" ? next : accessIncludingInstalls(next, install) })}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(ids, next) => apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))}
onReviewQuarantined={reviewQuarantined}
+48 -4
View File
@@ -16,7 +16,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "board-user", settled: true, failed: false }), resolveAccountUserId: async () => "board-user" }));
const intentSetupMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: { setupOptions: intentSetupMock } }));
const intentPhaseMock = vi.hoisted(() => vi.fn().mockResolvedValue({}));
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: { setupOptions: intentSetupMock, setPhase: intentPhaseMock } }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
@@ -588,6 +589,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp"));
await act(async () => buttonByText(connectLabel)!.click());
await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(connection.id, { asCurrentUser: true, interactionId: "intent-inline" }));
expect(intentPhaseMock).toHaveBeenCalledWith("intent-inline", "authorizing", {
connectionId: connection.id, access: { agentIds: ["agent-1"] },
});
expect(intentPhaseMock.mock.invocationCallOrder[0]).toBeLessThan(startOAuthMock.mock.invocationCallOrder[0]);
expect(popup.location.assign).toHaveBeenCalled();
expect(popup.opener).toBe(window);
popup.closed = true;
@@ -687,11 +692,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => {
const onCancel = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const intentKey = ["connection-intent", "intent-inline", "setup-options"];
client.setQueryData(intentKey, { interaction: { payload: { phase: "requested" } } });
intentPhaseMock.mockImplementation(async (_id, phase, setupAccess) => ({ payload: { phase, setupAccess } }));
const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [] });
getConnectionMock.mockResolvedValue(connection);
const content = <ConnectionSetupFlow host="dialog" serviceSlug="zapier" requestedAgentId="agent-1" interactionId="intent-inline" onCancel={onCancel} />;
const root = await render(undefined, false, content);
getConnectionInstallsMock.mockResolvedValue({ installs: [] });
const content = (setupAccess?: { connectionId: string; access: { agentIds: string[] } }) => <ConnectionSetupFlow host="dialog" serviceSlug="zapier" requiredAgentId="agent-1" setupAccess={setupAccess} interactionId="intent-inline" onCancel={onCancel} />;
const root = await render(client, false, content());
await openAccessAdvanced();
await act(async () => radioContaining("Just agents I pick")!.click());
await flushReact();
await act(async () => buttonByText("Select agents")!.click());
await flushReact();
await act(async () => document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]')!.click());
await flushReact();
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp?token=fixture-secret"));
await act(async () => buttonByText("Save & exit")!.click());
await vi.waitFor(() => expect(onCancel).toHaveBeenCalled());
@@ -700,13 +717,19 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(JSON.stringify(sessionStorage)).not.toContain("fixture-secret");
expect(finishAppMock).not.toHaveBeenCalled();
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
expect(intentPhaseMock).toHaveBeenCalledWith("intent-inline", "requested", {
connectionId: connection.id, access: { agentIds: ["agent-1"] },
});
const saved = client.getQueryData<{ interaction: { payload: { setupAccess: { connectionId: string; access: { agentIds: string[] } } } } }>(intentKey)!;
expect(saved.interaction.payload.setupAccess).toEqual({ connectionId: connection.id, access: { agentIds: ["agent-1"] } });
await act(async () => root.unmount());
mountedRoot = null;
await render(undefined, false, content);
await render(client, false, content(saved.interaction.payload.setupAccess));
await vi.waitFor(() => expect(container.textContent).toContain("MCP server URL"));
expect(getConnectionMock).toHaveBeenCalledWith(connection.id);
// Resuming lands on the same single screen it was saved from, not "step 2".
expect(container.textContent).not.toContain("Step 2 of 2");
expect(container.textContent).toContain("available to 1 selected agent");
});
it.each(["page", "dialog"] as const)("lets %s setup cancel after an invalid provider URL without trying to save it", async (host) => {
@@ -3034,11 +3057,32 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
asCurrentUser: true,
interactionId,
});
// The task already stores its restricted audience. Hidden form defaults
// must not replace it with all_agents when sign-in is resumed.
expect(intentPhaseMock.mock.calls.some(call => call[2] !== undefined)).toBe(false);
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=existing",
);
});
it("requires the task requester in a new OAuth audience before starting provider sign-in", async () => {
listGalleryMock.mockResolvedValue({ apps: [NOTION] });
connectAppMock.mockResolvedValue({ connectionId: "conn-notion", connection: { id: "conn-notion", credentialPolicy: "shared" }, auth: { kind: "oauth" } });
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="notion" interactionId="intent-1" requiredAgentId="agent-1" />);
await openAccessAdvanced();
await act(async () => radioContaining("Just agents I pick")!.click());
await flushReact();
await act(async () => buttonByText("Select agents")!.click());
await flushReact();
await act(async () => document.body.querySelector<HTMLElement>('[aria-label="Allow Grace"]')!.click());
await flushReact();
await passAccessStep();
await submitCuratedOAuthSetup();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(intentPhaseMock).not.toHaveBeenCalled();
});
it("resumes the exact draft selected by Finish setup", async () => {
mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });