mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
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(
|
||||
|
||||
Reference in new issue
Block a user