fix(api): publish agent-grant access operations as board_or_agent in the API catalog (#20349)

## 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
(`/<prefix>/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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 13:52:43 -06:00
1 parent c67909cb0f
commit 16e9157577
2 files changed
+35 -6

No files matched your search

@@ -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("/<prefix>/company/settings/members");
}
expect(spec.paths["/api/companies/{companyId}/members"].get.description).toContain("/<prefix>/company/settings/members");
});
it("documents owned conversation reads and exact revision access separately", () => {
const spec = buildOpenApiSpec() as any;
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
+10 -6
View File
@@ -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 /<prefix>/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 /<prefix>/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 /<prefix>/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 /<prefix>/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 /<prefix>/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),