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

@@ -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,