fix(connections): let agents outside a grant request scoped access to large catalogs (#19327)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 15:41:08 -06:00
1 parent 9a79384958
commit 2bd2e650fe
2 files changed
+61 -2

No files matched your search

@@ -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 });