Merge connection authority and saved consent fixes

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/agent-connection-api-parity:
  Preserve scoped policy audiences and Google consent choices
This commit is contained in:
DottaandPaperclip committed 2026-10-26 11:52:51 -05:00
commit 7eca024e1e
3 files changed
+57 -4

No files matched your search

@@ -1,4 +1,4 @@
import { defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared";
import { googleWorkspaceSelectedScopes, defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared";
import { randomUUID, generateKeyPairSync } from "node:crypto";
import { join } from "node:path";
import { createServer } from "node:http";
@@ -321,6 +321,21 @@ describe("connection management with production default agents", () => {
expect(await f.request("GET", `${path}/${hidden.id}`)).toMatchObject({ status: 404 });
});
it("keeps narrow policy create, update, reorder, and delete within their actual audiences", async () => {
const f = await fixture();
const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!;
await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure")));
const path = `/companies/${f.companyId}/tools/policies`;
const created = await f.request("POST", path, { name: "Own target rule", policyType: "allow", selectors: { agentId: f.agentId } });
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
expect(await f.request("PATCH", `${path}/${created.body.id}`, { description: "Retain the target" })).toMatchObject({ status: 200 });
expect(await f.request("POST", `${path}/reorder`, { policyIds: [created.body.id] })).toMatchObject({ status: 200 });
expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: {} })).toMatchObject({ status: 403 });
expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: { agentId: other.id } })).toMatchObject({ status: 403 });
expect(await f.request("POST", path, { name: "Company rule", policyType: "allow", selectors: {} })).toMatchObject({ status: 403 });
expect(await f.request("DELETE", `${path}/${created.body.id}`)).toMatchObject({ status: 200 });
});
it("bounds and deduplicates resource selectors before per-resource database work", async () => {
const f = await fixture();
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Bounded selector", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
@@ -437,6 +452,25 @@ describe("connection management with production default agents", () => {
expect(activity).toEqual(expect.arrayContaining([expect.objectContaining({ actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId })]));
});
it("saves the addressed user's Google service choices before handing off browser consent", async () => {
const f = await fixture();
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Selected Google services", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
config: { url: "https://1.1.1.1/mcp", sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace" } });
expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
const services = { gmail: "read" as const };
const started = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true, workspaceServices: services });
expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } });
const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
expect((saved.config.oauth as { pendingWorkspaceServiceRequests: Record<string, unknown> }).pendingWorkspaceServiceRequests).toEqual({
[f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services), requestedAt: expect.any(String) },
});
const restored = await f.request("GET", `/tool-connections/${saved.id}`);
expect(restored).toMatchObject({ status: 200, body: { config: { oauth: { pendingWorkspaceServiceRequests: {
[f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services) },
} } } } });
expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, saved.id))).toHaveLength(0);
});
it("discovers and invokes connection APIs through default production native bindings", async () => {
const f = await fixture();
const definitions = await f.authority.definitions();
@@ -119,6 +119,8 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
const extraResources: ConnectionManagementResource[] = [];
const bindingTargets: { targetType: string; targetId: string }[] = [];
const selectors: Record<string, unknown>[] = [];
const policyAudiences: Record<string, unknown>[] = [];
if (Object.hasOwn(body, "selectors")) policyAudiences.push(body.selectors ?? {});
const selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds", "routineId", "routineIds"];
const seenSelectors = new Set<string>();
const addSelector = (selected: Record<string, unknown>) => {
@@ -149,11 +151,11 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
for (const id of new Set(Array.isArray(body.policyIds) ? body.policyIds : [])) {
const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(id)));
if (policy && companyId && policy.companyId !== companyId && req.actor.type === "board") throw new HttpError(422, "Policies must belong to this company");
bindCompany(policy); addSelector(policy.selectors);
bindCompany(policy); addSelector(policy.selectors); policyAudiences.push(policy.selectors);
}
if (req.params.policyId) {
const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(req.params.policyId)));
bindCompany(policy); addSelector(policy.selectors);
bindCompany(policy); addSelector(policy.selectors); policyAudiences.push(policy.selectors);
}
if (action === "connections:runtime" && (req.params.slotId || req.params.id)) {
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, uuid(req.params.slotId ?? req.params.id)));
@@ -309,7 +311,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
for (const member of Array.isArray(body.config?.members) ? body.config.members : []) addSelector(member.binding ?? {});
for (const binding of Array.isArray(body.bindings) ? body.bindings : []) addSelector(binding);
if (req.method !== "GET" && (operation.includes("/tools/policies") || operation.includes("/trust-rules/")) &&
selectors.some(selected => !selected.agentId && !(Array.isArray(selected.agentIds) && selected.agentIds.length))) {
(policyAudiences.length ? policyAudiences : [{}]).some(selected => !selected.agentId && !(Array.isArray(selected.agentIds) && selected.agentIds.length))) {
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
companyAgents.forEach(agent => targets.add(agent.id));
}
@@ -181,6 +181,23 @@ export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies
// An agent has no browser session. Reuse the saved setup; the addressed
// human starts provider authorization in their own authenticated session.
// No provider state or credentials are issued by this handoff.
if (sourceTemplateKey === "google-workspace" && (input.workspaceServices || input.scopes)) {
await input.actor.managementContext.reauthorize();
const userId = input.actor.managementContext.responsibleUserId!;
await db.transaction(async tx => {
const [saved] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).for("no key update");
if (!saved) throw notFound("Connection not found");
const rawOauth = saved.config.oauth;
const oauth = rawOauth && typeof rawOauth === "object" && !Array.isArray(rawOauth) ? rawOauth as Record<string, unknown> : {};
const prior = oauth.pendingWorkspaceServiceRequests;
const pending = prior && typeof prior === "object" && !Array.isArray(prior) ? prior as Record<string, unknown> : {};
const requested = { ...pending, [userId]: { requestedScopes, requestedAt: now().toISOString() } };
const config = { ...saved.config, oauth: { ...oauth, pendingWorkspaceServiceRequests: requested } };
const transportOauth = saved.transportConfig.oauth;
const transportConfig = { ...saved.transportConfig, oauth: { ...(transportOauth && typeof transportOauth === "object" && !Array.isArray(transportOauth) ? transportOauth : {}), pendingWorkspaceServiceRequests: requested } };
await tx.update(toolConnections).set({ config, transportConfig, updatedAt: now() }).where(eq(toolConnections.id, saved.id));
});
}
const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId));
if (!company) throw notFound("Company not found");
const destination = new URL(input.redirectUri);