diff --git a/.github/scripts/tests/ci-profile.test.mjs b/.github/scripts/tests/ci-profile.test.mjs index f113d0ad87d..313e8ece372 100644 --- a/.github/scripts/tests/ci-profile.test.mjs +++ b/.github/scripts/tests/ci-profile.test.mjs @@ -241,11 +241,12 @@ test("workflow exporter runs trusted code and keeps keys out of the PR jobs", () const pr = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8"); // A script filename such as docker-compose-secrets.test.mjs is not a // GitHub secret lookup. Check expression context, including bracket access. - const secretReference = /HONEYCOMB.*KEY|\$\{\{[^}]*\bsecrets\s*(?:\.|\[)/; + const secretReference = /HONEYCOMB.*KEY|\$\{\{[^}]*\bsecrets\b/; assert.doesNotMatch("run: node --test scripts/docker-compose-secrets.test.mjs", secretReference); - for (const lookup of ["${{ secrets.PR_TOKEN }}", "${{ secrets['PR_TOKEN'] }}", "${{\n secrets.PR_TOKEN\n }}"]) { + for (const lookup of ["${{ secrets.PR_TOKEN }}", "${{ secrets['PR_TOKEN'] }}", "${{\n secrets.PR_TOKEN\n }}", "${{ toJSON(secrets) }}", "${{ secrets.HONEYCOMB_API_KEY }}"]) { assert.match(lookup, secretReference); } + assert.doesNotMatch(pr, secretReference); assert.match(pr, /if: always\(\) && env.PAPERCLIP_CI_PROFILE == 'true'/); assert.doesNotMatch(pr, /PAPERCLIP_CI_PROFILE:.*labels/); diff --git a/doc/observability.md b/doc/observability.md index 48f417cc2dc..bf93d0d7c9e 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -279,7 +279,8 @@ attempt or owning phase: Detail phases emit at most 16 spans of each name per attempt. Larger reader and asset inventories continue under the containing group without unbounded per-row -or per-file spans. Work, query order, authorization, and locks are unchanged. +or per-file spans. Adding detail spans does not itself change work, query order, +authorization, or locks. Manual transaction and dispatch boundaries retain their actual lifetime. Asset writes and verification may add only non-negative integer `paperclip.execution.fileCount` and `paperclip.execution.bytes` attributes. @@ -295,6 +296,28 @@ can continue through later bookkeeping and resource cleanup. A later recovery invocation has its own attempt; this does not manufacture one continuous trace across queueing, process restarts, or an unavailable controller. +### Connector preparation work + +Run configuration resolves the four connector families concurrently and preserves +registration order. Browser Use assignment reads only active Browser Use +connections and their eligible catalog entries. It applies the same tool policy, +grant, company, and restricted GitHub guest checks without joining the full tool +discovery queue or resolving unrelated Slack, GitHub, and Speko tools. + +Native startup registers direct connector tool definitions from the run's pinned +skills. It does not repeat assignment discovery. These definitions do not grant +access: each invocation still resolves current assignments and enters the existing +permission and approval gateway. A retained session may advertise a tool whose +access was revoked; the call fails under the current authorization state. + +Slack assignment checks stored catalog hashes against the bundled definitions. +A current catalog and existing profile need no catalog transaction or advisory +lock. Current profile bindings and entries still determine availability, followed +by one live task authority check. Missing or changed catalog definitions use the +existing locked reconciliation path, with authority checks before and after the +update. Disabled catalog entries and operator-removed bindings remain disabled. +No authorization result is cached across calls. + ### Reading a slow startup in Honeycomb Filter `paperclip.task.run.run_id` to the first 12 hex characters of diff --git a/server/src/__tests__/browser-use-connection.test.ts b/server/src/__tests__/browser-use-connection.test.ts index 90a227c55ad..87092b07a85 100644 --- a/server/src/__tests__/browser-use-connection.test.ts +++ b/server/src/__tests__/browser-use-connection.test.ts @@ -22,6 +22,7 @@ import { issues, toolConnections, toolApplications, + toolCatalogEntries, toolConnectionInstalls, toolProfiles, toolProfileBindings, @@ -419,6 +420,59 @@ const actor = { actorType: "user" as const, actorId: "browser-reviewer" }; expect(f.request).not.toHaveBeenCalled(); } finally { discovery.mockRestore(); } }); + it("resolves only the Browser Use catalog even while full discovery is saturated", async () => { + const f = await fixture(); + const [application] = await db.insert(toolApplications).values({ + companyId: f.company.id, name: "Unrelated MCP", type: "mcp_http", status: "active", + }).returning(); + const [connection] = await db.insert(toolConnections).values({ + companyId: f.company.id, applicationId: application.id, name: "Unrelated MCP", + uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok", + }).returning(); + await db.insert(toolCatalogEntries).values(Array.from({ length: 500 }, (_, i) => ({ + companyId: f.company.id, applicationId: application.id, connectionId: connection.id, + entryKind: "tool" as const, name: `unrelated_${i}`, toolName: `unrelated_${i}`, + inputSchema: { type: "object" }, riskLevel: "read" as const, status: "active" as const, + versionHash: "fixture", schemaHash: "fixture", + }))); + const { recordingDb } = await import("./helpers/tool-gateway-listing-fixture.js"); + const { toolDiscoveryScheduler } = await import("../services/tool-discovery-scheduler.js"); + const releases: Array<() => void> = []; + const blockers = [0, 1].map(() => toolDiscoveryScheduler.run(() => new Promise(resolve => releases.push(resolve)))); + await vi.waitFor(() => expect(releases).toHaveLength(2)); + const recorded = recordingDb(db); + const gateway = createToolGatewayService(recorded.db); + const pending = gateway.browserUseResources(f.binding); + let timeout: ReturnType | undefined; + try { + const resources = await Promise.race([pending, new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Assignment waited for unrelated discovery")), 5_000); + })]); + expect(resources).toEqual([{ id: f.connection.connectionId, connectionId: f.connection.connectionId, label: "Browser Use Cloud" }]); + const catalogReads = recorded.statements.flatMap((query, index) => + query.includes('from "tool_catalog_entries"') && query.includes('inner join "tool_connections"') ? [recorded.statementParams[index]] : []); + expect(catalogReads).toHaveLength(1); + expect(catalogReads[0]).toContain(f.connection.connectionId); + expect(catalogReads[0]).not.toContain(connection.id); + expect(recorded.statements.some(query => query.includes('from "chat_endpoints"'))).toBe(false); + } finally { + clearTimeout(timeout); + releases.forEach(release => release()); + await Promise.all(blockers); + await pending; + } + }); + it.each(["policy", "catalog", "grant"])("rechecks Browser Use %s revocation without a cached assignment", async kind => { + const f = await fixture(); + expect(await f.gateway.browserUseResources(f.binding)).toHaveLength(1); + if (kind === "policy") await db.insert(toolPolicies).values({ + companyId: f.company.id, name: "Revoke Browser Use", policyType: "block", + selectors: { connectionId: f.connection.connectionId }, priority: 100, + }); + if (kind === "catalog") await db.update(toolCatalogEntries).set({ quarantinedAt: new Date() }).where(eq(toolCatalogEntries.connectionId, f.connection.connectionId)); + if (kind === "grant") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.grant.id)); + expect(await f.gateway.browserUseResources(f.binding)).toEqual([]); + }); it("withholds Cloud instructions when connection tools are disabled", async () => { const f = await fixture(); await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connection.connectionId)); diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index b6d5f28c29f..231c24e3832 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -8096,10 +8096,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { it("refreshes Slack tool guidance without restoring disabled catalog policies", async () => { const { SLACK_TOOLS } = await import("@paperclipai/shared"); - const { syncSlackBotTools } = await import("../services/connectors/slack-catalog.js"); + const { slackBotCatalogIsCurrent, syncSlackBotTools } = await import("../services/connectors/slack-catalog.js"); const fixture = await seedCompany(); const { endpoint } = await configuredSlackEndpoint(fixture); + expect(await slackBotCatalogIsCurrent(db, endpoint)).toBe(false); await db.transaction(tx => syncSlackBotTools(tx, endpoint, "owner-user", true)); + expect(await slackBotCatalogIsCurrent(db, endpoint)).toBe(true); const tool = SLACK_TOOLS.find(tool => tool.name === "slack_post_message")!; // Simulate the previous hash contract, which omitted descriptions. const oldHash = createHash("sha256").update(JSON.stringify({ name: tool.name, inputSchema: tool.inputSchema, risk: tool.risk, scopes: tool.scopes })).digest("hex"); @@ -8108,7 +8110,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { description: "Old posting guidance", versionHash: oldHash, schemaHash: oldHash, status: "removed", reviewedByUserId: "owner-user", reviewedAt, }).where(and(eq(toolCatalogEntries.connectionId, endpoint.connectionId), eq(toolCatalogEntries.toolName, tool.name))); + expect(await slackBotCatalogIsCurrent(db, endpoint)).toBe(false); await db.transaction(tx => syncSlackBotTools(tx, endpoint, "different-operator", true)); + expect(await slackBotCatalogIsCurrent(db, endpoint)).toBe(true); const [updated] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, endpoint.connectionId), eq(toolCatalogEntries.toolName, tool.name))); expect(updated).toMatchObject({ description: tool.description, status: "removed", reviewedByUserId: "owner-user", reviewedAt }); expect(updated!.versionHash).not.toBe(oldHash); @@ -8132,6 +8136,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(assignments.map(item => item.key)).toEqual(["slack"]); expect(assignments[0].resources[0]).toMatchObject({ id: endpoint.id, metadata: { requesterId: "UROUTINE" } }); expect(assignments[0].tools.some(tool => tool.name === "slack_open_dm")).toBe(true); + const transactions = vi.spyOn(db, "transaction"); + try { + expect(await resolveConnectorAssignments(db, binding)).toEqual(assignments); + expect(transactions).toHaveBeenCalledTimes(2); + } finally { transactions.mockRestore(); } + await expect(resolveSlackTaskAuthority(db, binding)).resolves.toMatchObject({ conversation: null, userId: "owner-user", slackUserId: "UROUTINE" }); const upstream = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const method = String(input).split("/").at(-1); diff --git a/server/src/services/connector-runtime.test.ts b/server/src/services/connector-runtime.test.ts new file mode 100644 index 00000000000..a39667594b3 --- /dev/null +++ b/server/src/services/connector-runtime.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { resolveConnectorAssignments } from "./connector-runtime.js"; + +const resolvers = vi.hoisted(() => ({ + browser: vi.fn(), speko: vi.fn(), slack: vi.fn(), inboxes: vi.fn(), +})); +vi.mock("./native-runtime/assigned-mcp-tools.js", () => ({ getAssignedMcpGateway: () => ({ browserUseResources: resolvers.browser }) })); +vi.mock("./voice/speko-agent-tools.js", () => ({ spekoAssignedResources: resolvers.speko })); +vi.mock("./connectors/slack.js", () => ({ slackAssignedResource: resolvers.slack, executeGovernedSlackTool: vi.fn() })); +vi.mock("./email-channels.js", () => ({ emailChannelService: () => ({ assignedInboxes: resolvers.inboxes }) })); + +afterEach(() => vi.resetAllMocks()); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe("connector assignment preparation", () => { + it("starts independent resolvers together and retains connector order", async () => { + const pending = Array.from({ length: 4 }, () => deferred>()); + Object.values(resolvers).forEach((resolver, i) => resolver.mockReturnValue(pending[i].promise)); + const assignments = resolveConnectorAssignments({} as Db, { companyId: "company", agentId: "agent" }); + await vi.waitFor(() => Object.values(resolvers).forEach(resolver => expect(resolver).toHaveBeenCalledOnce())); + for (const i of [3, 2, 1, 0]) pending[i].resolve([{ id: `resource-${i}`, connectionId: `connection-${i}`, label: "Resource", address: "inbox@example.com" }]); + expect((await assignments).map(assignment => assignment.key)).toEqual(["browser-use-cloud", "speko", "slack", "agentmail"]); + }); + + it("settles outstanding resolution before propagating a failed connector", async () => { + const slack = deferred<[]>(); + resolvers.browser.mockResolvedValue([]); + resolvers.speko.mockRejectedValue(new Error("Speko unavailable")); + resolvers.slack.mockReturnValue(slack.promise); + resolvers.inboxes.mockResolvedValue([]); + let settled = false; + const assignments = resolveConnectorAssignments({} as Db, { companyId: "company", agentId: "agent" }); + const result = expect(assignments).rejects.toThrow("Speko unavailable").finally(() => { settled = true; }); + await vi.waitFor(() => expect(resolvers.slack).toHaveBeenCalledOnce()); + expect(settled).toBe(false); + slack.resolve([]); + await result; + }); +}); diff --git a/server/src/services/connector-runtime.ts b/server/src/services/connector-runtime.ts index 6ab346660f8..8db760ddf13 100644 --- a/server/src/services/connector-runtime.ts +++ b/server/src/services/connector-runtime.ts @@ -128,19 +128,25 @@ export async function resolveConnectorAssignments( db: Db, binding: AgentBinding, ): Promise { - const assignments: ConnectorAssignment[] = []; - for (const connector of connectors) { + const results = await Promise.allSettled(connectors.map(async (connector) => { const resources = await measureRunPhase(connector.resolvePhase, () => connector.resolve(db, binding)); - if (resources.length) - assignments.push({ - key: connector.key, - label: connector.label, - skillKey: skillKey(connector), - resources, - tools: connector.tools, - }); - } - return assignments; + return resources.length ? [{ + key: connector.key, + label: connector.label, + skillKey: skillKey(connector), + resources, + tools: connector.tools, + }] : []; + })); + return results.flatMap(result => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); +} + +export function connectorToolDefinitionsForSkills(skillKeys: ReadonlySet): Pick[] { + return connectors.filter(connector => skillKeys.has(skillKey(connector)) && connector.tools.length > 0) + .map(connector => ({ tools: connector.tools })); } export function isConnectorSkill(key: string) { diff --git a/server/src/services/connectors/slack-authority.ts b/server/src/services/connectors/slack-authority.ts index 31b125a92f3..d5a355f9aa6 100644 --- a/server/src/services/connectors/slack-authority.ts +++ b/server/src/services/connectors/slack-authority.ts @@ -187,7 +187,7 @@ export async function slackEndpointCandidates( binding: Pick, ) { return db - .select({ id: chatEndpoints.id }) + .select({ id: chatEndpoints.id, connectionId: chatEndpoints.connectionId }) .from(chatEndpoints) .where( and( diff --git a/server/src/services/connectors/slack-catalog.ts b/server/src/services/connectors/slack-catalog.ts index 184695da3b2..cd0b2d79f7d 100644 --- a/server/src/services/connectors/slack-catalog.ts +++ b/server/src/services/connectors/slack-catalog.ts @@ -20,6 +20,36 @@ import { resolveSlackTaskAuthority, slackEndpointCandidates, } from "./slack-authority.js"; +const catalogDefinitions = SLACK_TOOLS.map(tool => ({ + tool, + hash: createHash("sha256").update(JSON.stringify({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + risk: tool.risk, + scopes: tool.scopes, + })).digest("hex"), +})); + +export async function slackBotCatalogIsCurrent( + db: Db, + endpoint: { id: string; companyId: string; connectionId: string }, +): Promise { + const [entries, profiles] = await Promise.all([ + db.select({ name: toolCatalogEntries.name, versionHash: toolCatalogEntries.versionHash }) + .from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, endpoint.companyId), + eq(toolCatalogEntries.connectionId, endpoint.connectionId), + )), + db.select({ id: toolProfiles.id }).from(toolProfiles).where(and( + eq(toolProfiles.companyId, endpoint.companyId), + eq(toolProfiles.profileKey, `slack-bot:${endpoint.id}`), + )).limit(1), + ]); + const hashes = new Map(entries.map(entry => [entry.name, entry.versionHash])); + return profiles.length > 0 && catalogDefinitions.every(({ tool, hash }) => hashes.get(`slack_bot:${tool.name}`) === hash); +} + export async function syncSlackBotTools( tx: Parameters[0]>[0], endpoint: typeof chatEndpoints.$inferSelect, @@ -46,18 +76,7 @@ export async function syncSlackBotTools( const entriesByName = new Map( existingEntries.map((entry) => [entry.name, entry]), ); - for (const tool of SLACK_TOOLS) { - const hash = createHash("sha256") - .update( - JSON.stringify({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - risk: tool.risk, - scopes: tool.scopes, - }), - ) - .digest("hex"); + for (const { tool, hash } of catalogDefinitions) { const values = { companyId: endpoint.companyId, applicationId: connection.applicationId, diff --git a/server/src/services/connectors/slack.ts b/server/src/services/connectors/slack.ts index 3053feb2867..0298ec6b7da 100644 --- a/server/src/services/connectors/slack.ts +++ b/server/src/services/connectors/slack.ts @@ -2,7 +2,7 @@ import { authorizeSlackDocument } from "./slack-document.js"; import { slackDelivery } from "./slack-delivery.js"; import { slackMessage } from "./slack-message.js"; import { executeSlackWrite } from "./slack-writes.js"; -import { syncSlackBotTools } from "./slack-catalog.js"; +import { slackBotCatalogIsCurrent, syncSlackBotTools } from "./slack-catalog.js"; import { SLACK_TOOLS } from "@paperclipai/shared"; import { measureRunPhase } from "../execution-preparation-timing.js"; import { measureRunTransaction } from "../execution-transaction-timing.js"; @@ -34,28 +34,24 @@ import { async function slackAssignedEndpoint( db: Db, binding: Partial & { companyId: string; agentId: string }, + endpoint: { id: string; connectionId: string }, ) { if (!binding.runId || !binding.issueId) return []; - let authority: Awaited>; - try { - authority = await measureRunPhase("connectors.slack.authority", () => resolveSlackTaskAuthority( - db, - binding as SlackTaskBinding, - )); - } catch (error) { - if ( - error && - typeof error === "object" && - "status" in error && - error.status === 403 - ) - return []; - throw error; + const authorize = async () => { + try { + return await measureRunPhase("connectors.slack.authority", () => resolveSlackTaskAuthority(db, binding as SlackTaskBinding)); + } catch (error) { + if (error && typeof error === "object" && "status" in error && error.status === 403) return null; + throw error; + } + }; + if (!await slackBotCatalogIsCurrent(db, { ...endpoint, companyId: binding.companyId })) { + const authority = await authorize(); + if (!authority || authority.endpoint.connectionId !== endpoint.connectionId) return []; + await measureRunTransaction(db, "connectors.slack.catalog", (tx) => + syncSlackBotTools(tx, authority.endpoint, authority.userId, true), + ); } - // Catalog updates are idempotent and preserve configured policies. - await measureRunTransaction(db, "connectors.slack.catalog", (tx) => - syncSlackBotTools(tx, authority.endpoint, authority.userId, true), - ); const [enabledProfile] = await measureRunPhase("connectors.slack.profile", () => db .select({ id: toolProfiles.id }) .from(toolProfiles) @@ -72,20 +68,21 @@ async function slackAssignedEndpoint( toolProfileEntries, and( eq(toolProfileEntries.profileId, toolProfiles.id), - eq(toolProfileEntries.connectionId, authority.endpoint.connectionId), + eq(toolProfileEntries.connectionId, endpoint.connectionId), eq(toolProfileEntries.effect, "include"), ), ) .where( and( eq(toolProfiles.companyId, binding.companyId), - eq(toolProfiles.profileKey, `slack-bot:${authority.endpoint.id}`), + eq(toolProfiles.profileKey, `slack-bot:${endpoint.id}`), eq(toolProfiles.status, "active"), ), ) .limit(1)); if (!enabledProfile) return []; - authority = await measureRunPhase("connectors.slack.authority", () => resolveSlackTaskAuthority(db, binding as SlackTaskBinding)); + const authority = await authorize(); + if (!authority || authority.endpoint.connectionId !== endpoint.connectionId) return []; return [ { id: authority.endpoint.id, @@ -113,7 +110,7 @@ export async function slackAssignedResource( ...(await slackAssignedEndpoint(db, { ...binding, endpointId: endpoint.id, - })), + }, endpoint)), ); } return resources; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 0a48050d67b..a4431a8d711 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -35,7 +35,7 @@ import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js"; import { selectRemotePiCompanion } from "./remote-pi-companion.js"; import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js"; import { remoteLeaseCleanupScope } from "../remote-execution-termination.js"; -import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js"; +import { connectorToolDefinitionsForSkills } from "../connector-runtime.js"; import { activeNativeExecutionRunIds } from "./native-active-runs.js"; import { preserveNativeFailedWorkspace } from "./native-failed-workspace-recovery.js"; import { @@ -11447,8 +11447,7 @@ async function createRunnerdBackendWithinSessionClaim( input.execution.binding, )); const pinnedSkills = new Set("runtimeContext" in input.execution ? input.execution.runtimeContext.skills.map((skill) => skill.key) : []); - const connectorAssignments = [...pinnedSkills].some(isConnectorSkill) - ? await timeController("connector_assignments", () => resolveConnectorAssignments(input.db, input.execution.binding)) : []; + const connectorAssignments = connectorToolDefinitionsForSkills(pinnedSkills); const reviewRun = await input.db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns).where(and( eq(heartbeatRuns.id, input.execution.binding.runId), @@ -11489,7 +11488,7 @@ async function createRunnerdBackendWithinSessionClaim( )).limit(1).then(rows => rows[0]) : undefined; const authority = new PaperclipRunnerToolAuthority(input.db, { ...(nativeReview ? { nativeReview } : {}), - connectorAssignments: connectorAssignments.filter((assignment) => pinnedSkills.has(assignment.skillKey)), + connectorAssignments, assignedMcpTools, companyId: input.execution.binding.companyId, issueId: input.execution.binding.issueId, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 39471e2da24..f9c88e4afa0 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -1,3 +1,4 @@ +import { connectorToolDefinitionsForSkills } from "../connector-runtime.js"; import * as cloudIdentity from "../cloud-runtime-identity.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; @@ -96,6 +97,18 @@ describe("PaperclipRunnerToolAuthority", () => { await temporary?.cleanup(); }); + it("registers pinned connector definitions without discovery and rejects revoked assignments at use", async () => { + const connectorAssignments = connectorToolDefinitionsForSkills(new Set([ + "paperclipai/paperclip/slack", "paperclipai/paperclip/browser-use-cloud", "custom/agentmail", + ])); + expect(connectorAssignments).toHaveLength(1); + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, connectorAssignments }); + expect(authority.definitions().some(tool => tool.name === "slack_history")).toBe(true); + expect(authority.definitions().some(tool => String(tool.name).startsWith("agentmail_"))).toBe(false); + await expect(authority.execute({ tool: "slack_history", callId: randomUUID(), arguments: { channel: "CEXAMPLE" } })) + .rejects.toThrow("This connector is no longer assigned or authorized"); + }); + it("advertises only real bindings and reads the bound task", async () => { const authority = new PaperclipRunnerToolAuthority(db, { companyId, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index a7f69d73d4e..cbe300ec2da 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -139,7 +139,7 @@ type Binding = { apiUrl?: string; storage?: StorageService; /** Server-owned suppression for baseline evals; true never overrides operator opt-in. */ - connectorAssignments?: ConnectorAssignment[]; + connectorAssignments?: Pick[]; assignedMcpTools?: Awaited>; apiToolsEnabled?: boolean; workMode?: "standard" | "planning" | "ask"; diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index bb1c0e9192a..61693f5701c 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1328,7 +1328,9 @@ export function createToolGatewayService( async function connectedMcpToolsForCompany( companyId: string, + selectedConnectionIds?: string[], ): Promise { + if (selectedConnectionIds?.length === 0) return []; // Read each connection and application row once. Selecting them in the // catalog join repeats the full connection row (config included) for // every tool, and one connection can expose hundreds of tools. Both reads @@ -1354,6 +1356,7 @@ export function createToolGatewayService( eq(toolCatalogEntries.status, "active"), isNull(toolCatalogEntries.quarantinedAt), connectedMcpConnectionFilter(companyId), + selectedConnectionIds ? inArray(toolConnections.id, selectedConnectionIds) : undefined, ), ) .orderBy(toolConnections.name, toolCatalogEntries.name); @@ -9135,19 +9138,30 @@ export function createToolGatewayService( async browserUseResources(binding: { companyId: string; agentId: string; runId?: string; issueId?: string }) { if (!binding.runId || !binding.issueId) return []; - const [connection] = await measureRunPhase("connectors.browser_use.connection", () => db.select({ id: toolConnections.id }).from(toolConnections).where(and( + const connections = await measureRunPhase("connectors.browser_use.connection", () => db.select({ id: toolConnections.id }).from(toolConnections).where(and( eq(toolConnections.companyId, binding.companyId), eq(toolConnections.transport, "rest_api"), eq(toolConnections.enabled, true), eq(toolConnections.status, "active"), sql`${toolConnections.config}->>'sourceTemplateKey' = 'browser-use-cloud'`, - )).limit(1)); - if (!connection) return []; + )).orderBy(toolConnections.name, toolConnections.id)); + if (!connections.length) return []; const session: ToolGatewaySession = { ...binding, runId: binding.runId, issueId: binding.issueId, id: randomUUID(), token: "", projectId: null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) }; - const tools = (await measureRunPhase("connectors.browser_use.catalog", () => listToolsForContext(session))).filter(t => t.providerType === "provider_rest"); + await assertAgentInCompany(binding.companyId, binding.agentId); + if (await githubGuestBotConnectionForSession(db, session)) return []; + const tools = await measureRunPhase("connectors.browser_use.catalog", async () => { + const candidates = await connectedMcpToolsForCompany(binding.companyId, connections.map(connection => connection.id)); + const decisions = await decideToolsForListing(candidates, tool => policyInputForTool({ session, tool })); + return decisions.filter(({ decision }) => decision.allowed || decision.decision === "require_approval").map(({ tool }) => tool); + }); + const visible = new Set(tools.map(tool => tool.connectionId)); const resources: Array<{ id: string; label: string; connectionId: string }> = []; - for (const connectionId of new Set(tools.map(t => t.connectionId!))) { - const [connection] = await measureRunPhase("connectors.browser_use.connection", () => db.select().from(toolConnections).where(and(eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, connectionId)))); + for (const candidate of connections) { + if (!visible.has(candidate.id)) continue; + const [connection] = await measureRunPhase("connectors.browser_use.connection", () => db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, binding.companyId), eq(toolConnections.id, candidate.id), + eq(toolConnections.enabled, true), eq(toolConnections.status, "active"), + ))); if (!connection || !isBrowserUseConnection(connection)) continue; - try { await measureRunPhase("connectors.browser_use.grant", () => resolveConnectionGrant(session, connection, false)); resources.push({ id: connectionId, connectionId, label: "Browser Use Cloud" }); } catch { /* Unavailable grants do not contribute a runtime skill. */ } + try { await measureRunPhase("connectors.browser_use.grant", () => resolveConnectionGrant(session, connection, false)); resources.push({ id: connection.id, connectionId: connection.id, label: "Browser Use Cloud" }); } catch { /* Unavailable grants do not contribute a runtime skill. */ } } return resources; },