From 2bd2e650fe2cd8aeed36f2c26e577be6951fcc4a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:41:08 -0600 Subject: [PATCH] fix(connections): let agents outside a grant request scoped access to large catalogs (#19327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use company app connections, such as GitHub, through `connections_search` and `connection_request`. > - A per-user OAuth connection is shared only with the agent that requested it. Other agents must request their own scoped access. > - In a hands-on onboarding test, the CEO agent connected GitHub. A Frontend Engineer hired earlier did not get access. It asked for access and got a refusal that it could not act on. > - The engineer then reported "no GitHub access" while the CEO reported "GitHub is connected". The user saw this as inconsistent and unreliable. > - This pull request makes the refusal actionable. It also makes an agent with existing access get `ready`. > - The benefit is that each agent can request, and then accurately report, its own access to a connection. ## Linked Issues or Issue Description **What happened?** An onboarding test on staging connected GitHub through the CEO agent's connection request. The grant was delegated and installed only for the CEO. The Frontend Engineer (hired two minutes earlier) called `connection_request({ service: "github" })` and then `connection_request({ service: "github", connectionId })`. Both calls returned 422 `Specify the tools needed for this task (up to 20)`. The GitHub catalog has 46 active tools. The refusal gave no tool names. `connections_search` gave `connectionId: null` for that agent. The agent told the user that it had no GitHub access. The CEO said GitHub was connected. **Expected behavior** Each agent accurately reports, and can request and use, the connector access granted to it. **Steps to reproduce** 1. Connect a per-user GitHub connection (more than 20 indexed tools) through agent A's connection request. 2. Assign a task that needs GitHub to agent B, which existed before the grant. 3. Agent B calls `connection_request` with `service: "github"`. 4. The call fails with the bare "up to 20" message, and agent B cannot find the tool names to request. **Deployment mode** Paperclip cloud (staging). **Agent adapter(s) involved** Not adapter-specific (core bug). **Additional context** Root cause from the staging evidence: - Connection `GitHub` (oauth, `credentialPolicy: per_user`, 46 active tools). One user grant. Delegations and installs: CEO at 17:45, Frontend Engineer at 18:18. - Frontend Engineer run, 17:53–18:10: git push logged `GitHub capability_missing`. Two `connection_request` calls returned the 422 above. - The engineer found the names through `call_api` later and filed a scoped card at 18:10. The user approved it at 18:18. After that, the engineer had access. - The grant scope is intentional: a per-user grant is not shared automatically with other agents. The defect is the dead-end refusal, not the scope. - Repository creation after the grant (a separate onboarding report) is not in this PR. ## What Changed - `server/src/services/connection-intents.ts`: when a scoped access request needs more than 20 tools, the 422 now names the eligible connection id and lists its indexed tool names (first 100). It tells the agent to retry with only the needed names. No access is granted. - `server/src/services/connection-intents.ts`: a request without `toolNames` from an agent that already has allowed tools on the connection now reports `ready`. Before, it demanded a grant for every remaining catalog tool. - The guessed-tool-name path keeps its existing 20-name bound. - `server/src/__tests__/connection-intents-service.test.ts`: a regression test that reproduces the staging setup. The setup is a per-user GitHub grant shared with the CEO only, 46 tools, and an engineer outside the grant. ## Verification Red (test added first, service unchanged): ``` pnpm --dir server exec vitest run src/__tests__/connection-intents-service.test.ts -t "hired before a GitHub grant" FAIL connectionIntentService > lets an agent hired before a GitHub grant request the access the grant left out AssertionError: expected 'Specify the tools needed for this tas…' to contain '6c2d6faf-e059-4578-9ac4-8a5fbd81ecfa' Expected: "6c2d6faf-e059-4578-9ac4-8a5fbd81ecfa" Received: "Specify the tools needed for this task (up to 20)" Tests 1 failed | 24 skipped (25) ``` Green (after the fix; the same test): ``` Test Files 1 passed (1) Tests 1 passed | 24 skipped (25) ``` The test checks these steps: 1. The engineer's search gives `needs_user_action`. 2. The refusal names the connection and the needed tools (`create_repository`, `push_files`). 3. The scoped retry creates a card. 4. The user approves the card. 5. A plain `connection_request("github")` gives `ready` for that connection. During development, an intermediate version of the fix failed step 5 of the same test. That failure caused the second change. Neighbouring suites: - `connection-intents-service.test.ts`: 25 passed. - `connection-aggregator-fallback.test.ts`: 45 passed. This includes the bounded guessed-name recovery tests. - `satisfied-connection-intents.test.ts`: 52 passed. Its `afterAll` hook times out locally after 30 s. This also occurs on unchanged master code, so it is not caused by this change. `tsc --noEmit` for `server` gives no errors in the changed files. The local workspace had other errors because some workspace packages were not built. CI runs the full typecheck. ## Risks - Low risk. The refusal message is longer, and it lists up to 100 tool names. It lists names only for a connection that the requesting identity is already eligible to use (same check as before). - Behaviour shift: an agent with partial access that asks without `toolNames` gets `ready` and no new access card. To get more tools, the agent must name them. This matches the tool description ("pass connectionId and the exact required toolNames"). - No schema, API shape, or permission change. No access is granted without human approval. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, browser inspection of the staging run history, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../connection-intents-service.test.ts | 41 +++++++++++++++++++ server/src/services/connection-intents.ts | 22 +++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index 54b60ddd707..7d565b6ab1e 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -1001,6 +1001,47 @@ 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. + 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(); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "github-owner", membershipRole: "owner", status: "active" }); + const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Build storefront prototype (GitHub repo)", status: "in_progress", assigneeAgentId: engineer.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: engineer.id, status: "running", responsibleUserId: "github-owner", contextSnapshot: { issueId: issue.id } }).returning(); + const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "GitHub", type: "mcp_http", metadata: { sourceTemplateKey: "github" } }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: "GitHub", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", enabled: true, status: "active", healthStatus: "ok", config: { sourceTemplateKey: "github" } }).returning(); + 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 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 })), + ...writeTools.map(toolName => ({ toolName, riskLevel: "write" as const })), + ].map(tool => ({ ...tool, companyId: company.id, connectionId: connection.id, name: tool.toolName, versionHash: "v1", entryKind: "tool" as const, status: "active" as const }))); + const service = connectionIntentService(db); + const engineerClaims = { ...claims, sub: engineer.id, company_id: company.id, run_id: run.id, responsible_user_id: "github-owner" }; + + const search = await service.search(engineerClaims, "GitHub create private repository and push code"); + expect(search.results.find(result => result.service === "github")).toMatchObject({ state: "needs_user_action", connectionId: null }); + + // The engineer follows the tool guidance and asks for GitHub. The refusal must + // name the connection and its tools so the agent can make the scoped request. + 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); + 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"] }); + expect(scoped.state).toBe("needs_user_action"); + await service.complete(scoped.interactionId!, connection.id, "github-owner", { canManageOrganizationGrant: true }); + expect(await service.request(engineerClaims, "github")).toMatchObject({ state: "ready", connectionId: connection.id }); + }); + it("declines access without changing permissions and expires requests after reassignment", async () => { const f = await accessFixture(); const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id }); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index f0d143861e5..9544e73b1c8 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -125,6 +125,17 @@ function availableToolConnectionMethods( ); } +// Large enough for first-party catalogs such as GitHub's; aggregator catalogs +// stay bounded and still report their total. +const MAX_SCOPED_REQUEST_TOOL_NAMES = 100; + +/** Tells an agent outside a connection's grant how to request scoped access to it. */ +function scopedToolRequestGuidance(connectionId: string, catalog: ReadonlyArray<{ toolName: string }>) { + const names = [...new Set(catalog.map(tool => tool.toolName))].sort(); + const listed = names.slice(0, MAX_SCOPED_REQUEST_TOOL_NAMES); + return `Call connection_request again with connectionId ${JSON.stringify(connectionId)} and toolNames set to only the exact names this task needs. Available indexed tool names (first ${listed.length} of ${names.length}): ${JSON.stringify(listed)}. No access was granted.`; +} + export function connectionIntentService(db: Db) { const interactions = issueThreadInteractionService(db); const access = toolAccessService(db); @@ -810,9 +821,16 @@ export function connectionIntentService(db: Db) { } const effective = await access.getEffectiveToolAssignmentsForAgent(context.run.companyId, context.agent.id); const installed = effective.installedConnections.some(item => item.id === connection.id); - const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id)); + // Without toolNames the agent is asking whether it can use the service at all. + // Existing scoped access answers that; only named tools can request more. + const missing = !installed || (options.toolNames + ? 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))) { - if (tools.length > 20) throw unprocessable("Specify the tools needed for this task (up to 20)"); + // 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)}`); accessRequest = { connectionId: connection.id, connectionName: connection.name, tools: tools.map(tool => ({ catalogEntryId: tool!.id, toolName: tool!.toolName, versionHash: tool!.versionHash,