From 16e91575770a2d706b2e017d8b5e7f464f13b57a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:52:43 -0600 Subject: [PATCH] fix(api): publish agent-grant access operations as board_or_agent in the API catalog (#20349) 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 read the API catalog (`server/src/routes/openapi.ts`) to decide which operations they may call. The `x-paperclip-authorization` actor tells them who performs each operation. > - The invite, join-request and member-list routes admit agents that hold the matching company grant (`users:invite`, `joins:approve`, `users:manage_permissions`). > - The catalog still listed these operations as board-only. > - In a chat eval, a CEO agent with `users:invite` read `actor: "board"` on `POST /invites`, treated it as a final boundary, and told the person it could not invite anyone. > - This pull request publishes those operations as `board_or_agent` and names the grant each needs. It also names the Members page on member edits, which stay board-only. > - The benefit is that a granted agent uses the permission the board gave it, and agents without the grant still get a 403. ## Linked Issues or Issue Description **What happened?** A board granted the CEO agent `users:invite` with `PATCH /api/agents/:id/permission-grants`. The person asked in chat: "Invite alex@example.test to join this company." The agent searched the API catalog. It found `POST /api/companies/{companyId}/invites` with `x-paperclip-authorization: { actor: "board" }`. It concluded that invites are board-only and did not try the call. The route itself accepts agents with `users:invite`. **Expected behavior** The catalog shows `board_or_agent` for operations whose route accepts an agent with a company grant. The description names the grant. The agent creates the invite and returns the join link. **Steps to reproduce** 1. Grant an agent `users:invite`. 2. Read `GET /api/openapi.json` and find `POST /api/companies/{companyId}/invites`. 3. See `x-paperclip-authorization: { actor: "board" }`, although `assertCompanyPermission` admits the agent. **Paperclip version or commit** Master at the merge base of this branch. **Deployment mode** Any. Found in the runner full-stack chat eval `access.invite-teammate`. ## What Changed - Remove six operations from `BOARD_ONLY_OPERATIONS`: `POST`/`GET /companies/{companyId}/invites`, `GET /join-requests`, `POST /join-requests/{requestId}/approve` and `/reject`, and `GET /members`. Their routes use `assertCompanyPermission`, which admits agents with the grant. - Add descriptions that name the grant for each operation. The invite description also says it returns a shareable `inviteUrl` (no email field), and that membership starts only after acceptance. - Member edits (`PATCH /members/{memberId}`, `/role-and-grants`, `/permissions` and `POST /archive`) stay board-only. The route guard requires board access, and a test keeps the catalog in line with that guard. - Describe those member edits as board-only even for agents holding `users:manage_permissions`, and name the Members page (`//company/settings/members`) where a person makes the change. The member list description says the same. An agent then links the page instead of searching for an agent route. - Add a regression test in `openapi-routes.test.ts`. ## Verification - `cd server && node ../node_modules/vitest/vitest.mjs run --config vitest.config.ts src/__tests__/openapi-routes.test.ts` — 43 passed. The new test also checks that the Members page is named. With master's `openapi.ts`, the new test fails (`expected { actor: 'board' } to deeply equal { actor: 'board_or_agent' }`). - `src/services/native-runtime/runner-api.test.ts` — 2075 passed. ## Risks - Low risk. This changes catalog metadata only. Route guards do not change. An agent without the grant still gets 403. - Agents with these grants will now attempt these operations instead of stopping. That is the intended behavior of the grant. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code 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 --- server/src/__tests__/openapi-routes.test.ts | 25 +++++++++++++++++++++ server/src/routes/openapi.ts | 16 ++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 98d8ad9d8e3..5ab9bd45bfd 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -421,6 +421,31 @@ describe("openapi routes", () => { expect(create).toContain("/{prefix}/issues/{identifier}"); }); + it("publishes grant-gated access operations as board_or_agent and keeps member edits board-only", () => { + const spec = buildOpenApiSpec() as any; + for (const [path, method, grant] of [ + ["/api/companies/{companyId}/invites", "post", "users:invite"], + ["/api/companies/{companyId}/invites", "get", "users:invite"], + ["/api/companies/{companyId}/join-requests", "get", "joins:approve"], + ["/api/companies/{companyId}/join-requests/{requestId}/approve", "post", "joins:approve"], + ["/api/companies/{companyId}/join-requests/{requestId}/reject", "post", "joins:approve"], + ["/api/companies/{companyId}/members", "get", "users:manage_permissions"], + ] as const) { + const operation = spec.paths[path][method]; + expect(operation["x-paperclip-authorization"], `${method} ${path}`).toEqual({ actor: "board_or_agent" }); + expect(operation.description).toContain(grant); + } + expect(spec.paths["/api/companies/{companyId}/invites"].post.description).toContain("inviteUrl"); + for (const path of [ + "/api/companies/{companyId}/members/{memberId}/permissions", + "/api/companies/{companyId}/members/{memberId}/role-and-grants", + ]) { + expect(spec.paths[path].patch["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(spec.paths[path].patch.description).toContain("//company/settings/members"); + } + expect(spec.paths["/api/companies/{companyId}/members"].get.description).toContain("//company/settings/members"); + }); + it("documents owned conversation reads and exact revision access separately", () => { const spec = buildOpenApiSpec() as any; const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4ff52281987..6beaf9a6171 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1486,13 +1486,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "GET /api/companies/import/jobs/{jobId}", "POST /api/board-claim/{token}/claim", "GET /api/cli-auth/me", - "POST /api/companies/{companyId}/invites", - "GET /api/companies/{companyId}/invites", "POST /api/companies/{companyId}/openclaw/invite-prompt", - "GET /api/companies/{companyId}/join-requests", - "POST /api/companies/{companyId}/join-requests/{requestId}/approve", - "POST /api/companies/{companyId}/join-requests/{requestId}/reject", - "GET /api/companies/{companyId}/members", "PATCH /api/companies/{companyId}/members/{memberId}", "PATCH /api/companies/{companyId}/members/{memberId}/role-and-grants", "POST /api/companies/{companyId}/members/{memberId}/archive", @@ -7276,6 +7270,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/invites", tags: ["access"], summary: "List company invites", + description: "Board members or agents holding users:invite in this company. Lists invite records; it never returns invite tokens.", request: { params: z.object({ companyId: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7285,6 +7280,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/invites", tags: ["access"], summary: "Create a company invite", + description: "Board members or agents holding users:invite in this company. Creates a shareable join link (inviteUrl) that the person who should join opens; it does not send email and has no email field. Write the returned inviteUrl as a Markdown link in your reply. Membership starts only after the person accepts the link (and a join request is approved when required). humanRole defaults to operator.", request: { params: z.object({ companyId: z.string() }), body: jsonBody(createCompanyInviteSchema), @@ -7297,6 +7293,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/join-requests", tags: ["access"], summary: "List company join requests", + description: "Board members or agents holding joins:approve in this company.", request: { params: z.object({ companyId: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7306,6 +7303,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/join-requests/{requestId}/approve", tags: ["access"], summary: "Approve a company join request", + description: "Board members or agents holding joins:approve in this company.", request: { params: z.object({ companyId: z.string(), requestId: z.string() }), }, @@ -7317,6 +7315,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/join-requests/{requestId}/reject", tags: ["access"], summary: "Reject a company join request", + description: "Board members or agents holding joins:approve in this company.", request: { params: z.object({ companyId: z.string(), requestId: z.string() }), }, @@ -7363,6 +7362,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members", tags: ["access"], summary: "List company members", + description: "Board members or agents holding users:manage_permissions in this company. Returns each member's role, status and explicit grants. Changing a member's role, grants or status stays board-only: A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7372,6 +7372,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}", tags: ["access"], summary: "Update a company member status or role", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(updateCompanyMemberSchema), @@ -7389,6 +7390,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}/role-and-grants", tags: ["access"], summary: "Update a company member role and explicit grants", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(updateCompanyMemberWithPermissionsSchema), @@ -7406,6 +7408,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}/archive", tags: ["access"], summary: "Archive a company member", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(archiveCompanyMemberSchema), @@ -7423,6 +7426,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}/permissions", tags: ["access"], summary: "Update explicit company member permissions", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(updateMemberPermissionsSchema),