Files
paperclip/packages/plugins/sdk/tests/host-client-factory.test.ts
T
Dotta a1835cfa5e [codex] Harden plugin runtime invocation scope (#6547)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a company-scoped
control plane.
> - Plugins extend that control plane, but plugin workers still call
back into host APIs.
> - Those worker-to-host calls need the same company boundary guarantees
as normal API routes.
> - Plugin action handlers also need authenticated actor context from
the host instead of trusting caller-supplied params.
> - This pull request hardens plugin bridge/action scope and keeps
plugin operation issues out of normal issue surfaces.
> - The benefit is safer plugin execution with clearer authorization
boundaries and better test coverage.

## What Changed

- Added host-owned invocation context plumbing for nested plugin worker
calls.
- Added actor context to plugin `performAction` calls and test harness
helpers.
- Enforced company invocation scope on worker-to-host calls and filtered
company lists to the active invocation scope.
- Extended plugin action route tests for board and agent actor context,
spoofed company params, and cross-company rejection.
- Extended plugin worker manager coverage for invocation-scope
propagation.
- Filtered typed and legacy plugin operation issue origins from default
issue/inbox lists.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/plugins/sdk/tests/host-client-factory.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/plugin-worker-manager.test.ts
server/src/__tests__/issues-service.test.ts`

Note: embedded Postgres issue-service tests reported host-level Postgres
init skip for 47 tests; the non-embedded targeted tests passed.

## Risks

- Medium: plugin host authorization paths are sensitive, and external
plugins may rely on previously loose company params.
- Mitigation: the change only tightens calls when the host attached a
company invocation scope and includes explicit tests for board, agent,
and nested worker calls.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-05-22 09:16:24 -05:00

176 lines
5.5 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { HostServices } from "../src/host-client-factory.js";
import {
CapabilityDeniedError,
createHostClientHandlers,
InvocationScopeDeniedError,
} from "../src/host-client-factory.js";
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
describe("createHostClientHandlers invocation company scope", () => {
it("rejects company-scoped host calls outside the current invocation company", async () => {
const projectsList = vi.fn(async () => []);
const services = {
projects: {
list: projectsList,
},
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["projects.read"],
services,
});
await expect(
handlers["projects.list"](
{ companyId: "company-b" },
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
await expect(
handlers["projects.list"](
{ companyId: "company-b" },
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
});
expect(projectsList).not.toHaveBeenCalled();
});
it("filters companies.list to the current invocation company", async () => {
const services = {
companies: {
list: vi.fn(async () => [
{ id: "company-a", name: "Company A" },
{ id: "company-b", name: "Company B" },
]),
},
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["companies.read"],
services,
});
await expect(
handlers["companies.list"](
{},
{ invocationScope: { companyId: "company-a" } },
),
).resolves.toEqual([{ id: "company-a", name: "Company A" }]);
});
it("rejects company-scope store access for a different company", async () => {
const stateGet = vi.fn(async () => null);
const services = {
state: {
get: stateGet,
},
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["plugin.state.read"],
services,
});
await expect(
handlers["state.get"](
{ scopeKind: "company", scopeId: "company-b", stateKey: "settings" },
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
expect(stateGet).not.toHaveBeenCalled();
});
it.each([
[
"access.members.list",
"access.members.read",
{ companyId: "company-a" },
(services: HostServices) => vi.mocked(services.access.listMembers),
],
[
"access.members.update",
"access.members.write",
{ companyId: "company-a", memberId: "member-a", patch: { status: "active" } },
(services: HostServices) => vi.mocked(services.access.updateMember),
],
[
"authorization.grants.set",
"authorization.grants.write",
{ companyId: "company-a", principalType: "agent", principalId: "agent-a", grants: [] },
(services: HostServices) => vi.mocked(services.authorization.setGrants),
],
[
"authorization.policies.update",
"authorization.policies.write",
{ companyId: "company-a", resourceType: "agent", resourceId: "agent-a", policy: null },
(services: HostServices) => vi.mocked(services.authorization.updatePolicy),
],
[
"authorization.audit.search",
"authorization.audit.read",
{ companyId: "company-a" },
(services: HostServices) => vi.mocked(services.authorization.searchAudit),
],
] as const)(
"rejects %s when the plugin lacks %s",
async (method, capability, params, getDelegate) => {
const services = {
access: {
listMembers: vi.fn(async () => []),
updateMember: vi.fn(async () => ({ id: "member-a" })),
},
authorization: {
setGrants: vi.fn(async () => []),
updatePolicy: vi.fn(async () => ({ policy: null })),
searchAudit: vi.fn(async () => []),
},
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: [],
services,
});
await expect(
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[method](params),
).rejects.toMatchObject({
name: "CapabilityDeniedError",
message: expect.stringContaining(capability),
});
await expect(
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[method](params),
).rejects.toBeInstanceOf(CapabilityDeniedError);
expect(getDelegate(services)).not.toHaveBeenCalled();
},
);
it("checks invocation company scope before exposing authorization data", async () => {
const searchAudit = vi.fn(async () => []);
const services = {
authorization: {
searchAudit,
},
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["authorization.audit.read"],
services,
});
await expect(
handlers["authorization.audit.search"](
{ companyId: "company-b" },
{ invocationScope: { companyId: "company-a" } },
),
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
expect(searchAudit).not.toHaveBeenCalled();
});
});