[codex] Add agent permissions and controls plan (#6386)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies by keeping
task ownership, approvals, and operator control inside one control
plane.
> - Agent permissions and plugin-hosted company settings sit on the
boundary between autonomy and governance.
> - V1 needs scoped task assignment rules, plugin extension points, and
clearer company access surfaces without weakening company boundaries.
> - The branch builds the core authorization service, plugin SDK/host
APIs, and UI simplifications needed to support those controls.
> - Paperclip EE plugin surfaces were intentionally moved out of this
core PR per review direction, so this PR now carries only the public
core/plugin infrastructure work.
> - The latest updates preserve the PAP-9937 branch changes that belong
in this PR, remove the `design/` artifacts, and exclude the experimental
`plugin-briefs` package.
> - Greptile feedback was applied through the authorization/audit paths
and the final cleanup commit was re-reviewed at 5/5 with no unresolved
Greptile threads.
> - The benefit is safer assignment control with extension hooks for
richer permission products while preserving simple defaults for normal
operators.

## What Changed

- Added scoped task-assignment authorization decisions and routed
issue/agent assignment mutations through the authorization service.
- Added plugin SDK and host APIs for company settings slots,
authorization policy/grant management, assignment previews, and bridge
invocation scope propagation.
- Simplified core company access UI and moved advanced controls behind
plugin-provided settings surfaces.
- Added retry-now affordances for blocked issue next-step notices.
- Added protected-assignment enforcement for persisted
agent/project/issue policies, including explicit-grant fallback
behavior.
- Added incremental principal-access compatibility backfill for active
agent memberships and role-default human permission grants.
- Added the Markdown code block wrap action fix from the latest branch
changes.
- Removed `design/` artifacts from the PR and removed
`packages/plugins/plugin-briefs` from the final diff.
- Addressed Greptile feedback for plugin actor sanitization, legacy
membership handling, audit pagination, unknown grant-scope metadata, and
startup test mocks.

## Verification

- `pnpm exec vitest run server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54
tests passed.
- `pnpm exec vitest run
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62
tests passed.
- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/plugin-access-authorization-host-services.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files
passed, 28 tests passed.
- `pnpm --filter @paperclipai/server typecheck` -> passed.
- `git diff --check` -> passed.
- `node ./scripts/check-docker-deps-stage.mjs` -> passed.
- `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed
with no lockfile update.
- `pnpm exec vitest run
ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed.
- `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0.
- GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`.
- Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0
comments/annotations added, 0 unresolved review threads.
- Confirmed the PR diff contains no `design/`,
`packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or
`.github/workflows` changes.

## Risks

- Medium: task assignment authorization paths are behaviorally stricter
for protected/private policy data, so existing plugin-authored policies
may block assignment until explicit grants or approval flows are
configured.
- Medium: plugin-host authorization APIs expand the surface area
available to trusted plugins and need careful review for company
scoping.
- Low: startup now performs a principal-access compatibility backfill,
but the migration and runtime backfill use conflict-tolerant inserts.

> 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 Codex, GPT-5 coding agent, tool-enabled workflow with shell,
git, and GitHub CLI access.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-22 08:12:52 -05:00
committed by GitHub
parent c91a062326
commit 38c185fb8b
102 changed files with 6744 additions and 395 deletions
@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareManagedCodexHome } from "./codex-home.js";
describe("codex managed home", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("treats a concurrently-created expected auth symlink as success", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
const originalSymlink = fs.symlink.bind(fs);
vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
await originalSymlink(source, target, type);
const error = new Error("file already exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
});
try {
await expect(
prepareManagedCodexHome(
{
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
},
async () => {},
"company-1",
),
).resolves.toBe(managedCodexHome);
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) return false;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return false;
return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
}
async function createExpectedSymlink(target: string, source: string): Promise<void> {
try {
await fs.symlink(source, target);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
throw error;
}
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
return;
}
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
if (await isExpectedSymlink(target, source)) return;
await fs.unlink(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
@@ -0,0 +1,29 @@
INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
"company_id",
'user',
"principal_id",
'environments:manage',
NULL,
NULL,
now(),
now()
FROM "company_memberships"
WHERE "principal_type" = 'user'
AND "status" = 'active'
AND "membership_role" IN ('owner', 'admin')
ON CONFLICT (
"company_id",
"principal_type",
"principal_id",
"permission_key"
) DO NOTHING;
@@ -0,0 +1,75 @@
INSERT INTO "company_memberships" (
"company_id",
"principal_type",
"principal_id",
"status",
"membership_role",
"created_at",
"updated_at"
)
SELECT
"company_id",
'agent',
"id",
'active',
'member',
now(),
now()
FROM "agents"
WHERE "status" NOT IN ('pending_approval', 'terminated')
ON CONFLICT (
"company_id",
"principal_type",
"principal_id"
) DO NOTHING;
INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
memberships."company_id",
'user',
memberships."principal_id",
role_defaults."permission_key",
NULL,
NULL,
now(),
now()
FROM "company_memberships" memberships
JOIN (
VALUES
('owner', 'agents:create'),
('owner', 'environments:manage'),
('owner', 'users:invite'),
('owner', 'users:manage_permissions'),
('owner', 'tasks:assign'),
('owner', 'joins:approve'),
('admin', 'agents:create'),
('admin', 'environments:manage'),
('admin', 'users:invite'),
('admin', 'tasks:assign'),
('admin', 'joins:approve'),
('operator', 'tasks:assign')
) AS role_defaults("membership_role", "permission_key")
ON role_defaults."membership_role" = CASE
WHEN memberships."membership_role" = 'owner' THEN 'owner'
WHEN memberships."membership_role" = 'admin' THEN 'admin'
WHEN memberships."membership_role" = 'viewer' THEN 'viewer'
WHEN memberships."membership_role" = 'member' THEN 'operator'
ELSE 'operator'
END
WHERE memberships."principal_type" = 'user'
AND memberships."status" = 'active'
ON CONFLICT (
"company_id",
"principal_type",
"principal_id",
"permission_key"
) DO NOTHING;
@@ -610,6 +610,20 @@
"when": 1778976000000,
"tag": "0086_routine_env_runtime_contract",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1779360000000,
"tag": "0087_backfill_environment_manage_human_defaults",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1779446400000,
"tag": "0088_backfill_principal_access_compatibility",
"breakpoints": true
}
]
}
@@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
export const SLOT_IDS = {
page: "kitchen-sink-page",
settingsPage: "kitchen-sink-settings-page",
companySettingsPage: "kitchen-sink-company-settings-page",
dashboardWidget: "kitchen-sink-dashboard-widget",
sidebar: "kitchen-sink-sidebar-link",
sidebarPanel: "kitchen-sink-sidebar-panel",
@@ -23,6 +24,7 @@ export const SLOT_IDS = {
export const EXPORT_NAMES = {
page: "KitchenSinkPage",
settingsPage: "KitchenSinkSettingsPage",
companySettingsPage: "KitchenSinkCompanySettingsPage",
dashboardWidget: "KitchenSinkDashboardWidget",
sidebar: "KitchenSinkSidebarLink",
sidebarPanel: "KitchenSinkSidebarPanel",
@@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
displayName: "Kitchen Sink Settings",
exportName: EXPORT_NAMES.settingsPage,
},
{
type: "companySettingsPage",
id: SLOT_IDS.companySettingsPage,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.companySettingsPage,
routePath: "kitchen-sink",
},
{
type: "dashboardWidget",
id: SLOT_IDS.dashboardWidget,
@@ -10,6 +10,7 @@ import {
usePluginToast,
type PluginCommentAnnotationProps,
type PluginCommentContextMenuItemProps,
type PluginCompanySettingsPageProps,
type PluginDetailTabProps,
type PluginPageProps,
type PluginProjectSidebarItemProps,
@@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
);
}
export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
const href = hostNavigation.resolveHref("/company/settings/kitchen-sink");
return (
<div style={layoutStack}>
<Section title="Company Settings Slot">
<div style={subtleCardStyle}>
<div style={{ display: "grid", gap: "8px" }}>
<strong>Mounted inside company settings</strong>
<div style={mutedTextStyle}>
This fixture proves a ready plugin can add a settings sidebar item and render with company context.
</div>
<JsonBlock value={{
companyId: context.companyId,
companyPrefix: context.companyPrefix,
route: href,
pluginId: overview.data?.pluginId ?? PLUGIN_ID,
}} />
</div>
</div>
</Section>
</div>
);
}
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
+11 -2
View File
@@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
@@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope.
## Scheduled (recurring) jobs
@@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `activity.read` |
| | `costs.read` |
| | `issues.orchestration.read` |
| | `access.members.read` |
| | `access.invites.read` |
| | `authorization.grants.read` |
| | `authorization.policies.read` |
| | `authorization.audit.read` |
| | `database.namespace.read` |
| | `issues.create` |
| | `issues.update` |
@@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `local.folders` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
| | `access.members.write` |
| | `access.invites.write` |
| | `authorization.grants.write` |
| | `authorization.policies.write` |
| | `agent.sessions.create` |
| | `agent.sessions.list` |
| | `agent.sessions.send` |
+186 -5
View File
@@ -49,7 +49,7 @@
*/
import type { PluginCapability } from "@paperclipai/shared";
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
// ---------------------------------------------------------------------------
@@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error {
}
}
/**
* Thrown when a worker→host call asks for company-scoped data outside the
* company authorized for the current top-level plugin invocation.
*/
export class InvocationScopeDeniedError extends Error {
override readonly name = "InvocationScopeDeniedError";
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
constructor(pluginId: string, method: string, message: string) {
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
}
}
// ---------------------------------------------------------------------------
// Host service interfaces
// ---------------------------------------------------------------------------
@@ -257,6 +270,28 @@ export interface HostServices {
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
};
/** Provides `access.members.*` and `access.invites.*`. */
access: {
listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise<WorkerToHostMethods["access.members.list"][1]>;
getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise<WorkerToHostMethods["access.members.get"][1]>;
updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise<WorkerToHostMethods["access.members.update"][1]>;
listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise<WorkerToHostMethods["access.invites.list"][1]>;
createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise<WorkerToHostMethods["access.invites.create"][1]>;
revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise<WorkerToHostMethods["access.invites.revoke"][1]>;
};
/** Provides authorization grant, policy, preview, and audit helpers. */
authorization: {
listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise<WorkerToHostMethods["authorization.grants.list"][1]>;
setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise<WorkerToHostMethods["authorization.grants.set"][1]>;
policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise<WorkerToHostMethods["authorization.policies.summary"][1]>;
getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise<WorkerToHostMethods["authorization.policies.get"][1]>;
updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise<WorkerToHostMethods["authorization.policies.update"][1]>;
previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.previewAssignment"][1]>;
explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.explainAssignment"][1]>;
searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise<WorkerToHostMethods["authorization.audit.search"][1]>;
};
}
// ---------------------------------------------------------------------------
@@ -292,6 +327,7 @@ export interface HostClientFactoryOptions {
*/
type HostHandler<M extends WorkerToHostMethodName> = (
params: WorkerToHostMethods[M][0],
context?: WorkerHostCallContext,
) => Promise<WorkerToHostMethods[M][1]>;
/**
@@ -431,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"goals.get": "goals.read",
"goals.create": "goals.create",
"goals.update": "goals.update",
// Access
"access.members.list": "access.members.read",
"access.members.get": "access.members.read",
"access.members.update": "access.members.write",
"access.invites.list": "access.invites.read",
"access.invites.create": "access.invites.write",
"access.invites.revoke": "access.invites.write",
// Authorization
"authorization.grants.list": "authorization.grants.read",
"authorization.grants.set": "authorization.grants.write",
"authorization.policies.summary": "authorization.policies.read",
"authorization.policies.get": "authorization.policies.read",
"authorization.policies.update": "authorization.policies.write",
"authorization.policies.previewAssignment": "authorization.policies.read",
"authorization.policies.explainAssignment": "authorization.policies.read",
"authorization.audit.search": "authorization.audit.read",
};
// ---------------------------------------------------------------------------
@@ -461,6 +515,81 @@ export function createHostClientHandlers(
const { pluginId, services } = options;
const capabilitySet = new Set<PluginCapability>(options.capabilities);
type CompanyScopeRequest =
| { kind: "none" }
| { kind: "single"; companyId: string }
| { kind: "all" };
const noCompanyScope: CompanyScopeRequest = { kind: "none" };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function requestedCompanyScope(
method: WorkerToHostMethodName,
params: unknown,
): CompanyScopeRequest {
if (method === "companies.list") return { kind: "all" };
if (!isRecord(params)) return noCompanyScope;
const companyId = readNonEmptyString(params.companyId);
if (companyId) return { kind: "single", companyId };
if (params.scopeKind === "company") {
const scopeId = readNonEmptyString(params.scopeId);
return scopeId ? { kind: "single", companyId: scopeId } : { kind: "all" };
}
if (method === "events.subscribe" && isRecord(params.filter)) {
const filterCompanyId = readNonEmptyString(params.filter.companyId);
if (filterCompanyId) return { kind: "single", companyId: filterCompanyId };
}
return noCompanyScope;
}
function requireInvocationCompanyScope(
method: WorkerToHostMethodName,
params: unknown,
context?: WorkerHostCallContext,
): void {
const requested = requestedCompanyScope(method, params);
if (requested.kind === "none") return;
if (context?.invalidInvocationScope) {
throw new InvocationScopeDeniedError(
pluginId,
method,
"the worker referenced a missing, expired, or unknown invocation scope",
);
}
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
if (!allowedCompanyId) return;
if (requested.kind === "all") {
if (method === "companies.list") return;
throw new InvocationScopeDeniedError(
pluginId,
method,
`the current invocation is scoped to company "${allowedCompanyId}"`,
);
}
if (requested.companyId !== allowedCompanyId) {
throw new InvocationScopeDeniedError(
pluginId,
method,
`requested company "${requested.companyId}" but the current invocation is scoped to company "${allowedCompanyId}"`,
);
}
}
/**
* Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing.
@@ -485,9 +614,10 @@ export function createHostClientHandlers(
method: M,
handler: HostHandler<M>,
): HostHandler<M> {
return async (params: WorkerToHostMethods[M][0]) => {
return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
requireCapability(method);
return handler(params);
requireInvocationCompanyScope(method, params, context);
return handler(params, context);
};
}
@@ -591,8 +721,13 @@ export function createHostClientHandlers(
}),
// Companies
"companies.list": gated("companies.list", async (params) => {
return services.companies.list(params);
"companies.list": gated("companies.list", async (params, context) => {
const rows = await services.companies.list(params);
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
if (!allowedCompanyId) return rows;
return rows.filter((company) =>
isRecord(company) && company.id === allowedCompanyId,
) as WorkerToHostMethods["companies.list"][1];
}),
"companies.get": gated("companies.get", async (params) => {
return services.companies.get(params);
@@ -772,6 +907,52 @@ export function createHostClientHandlers(
"goals.update": gated("goals.update", async (params) => {
return services.goals.update(params);
}),
// Access
"access.members.list": gated("access.members.list", async (params) => {
return services.access.listMembers(params);
}),
"access.members.get": gated("access.members.get", async (params) => {
return services.access.getMember(params);
}),
"access.members.update": gated("access.members.update", async (params) => {
return services.access.updateMember(params);
}),
"access.invites.list": gated("access.invites.list", async (params) => {
return services.access.listInvites(params);
}),
"access.invites.create": gated("access.invites.create", async (params) => {
return services.access.createInvite(params);
}),
"access.invites.revoke": gated("access.invites.revoke", async (params) => {
return services.access.revokeInvite(params);
}),
// Authorization
"authorization.grants.list": gated("authorization.grants.list", async (params) => {
return services.authorization.listGrants(params);
}),
"authorization.grants.set": gated("authorization.grants.set", async (params) => {
return services.authorization.setGrants(params);
}),
"authorization.policies.summary": gated("authorization.policies.summary", async (params) => {
return services.authorization.policySummary(params);
}),
"authorization.policies.get": gated("authorization.policies.get", async (params) => {
return services.authorization.getPolicy(params);
}),
"authorization.policies.update": gated("authorization.policies.update", async (params) => {
return services.authorization.updatePolicy(params);
}),
"authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => {
return services.authorization.previewAssignment(params);
}),
"authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => {
return services.authorization.explainAssignment(params);
}),
"authorization.audit.search": gated("authorization.audit.search", async (params) => {
return services.authorization.searchAudit(params);
}),
};
}
+26
View File
@@ -58,6 +58,7 @@ export {
createHostClientHandlers,
getRequiredCapability,
CapabilityDeniedError,
InvocationScopeDeniedError,
} from "./host-client-factory.js";
// JSON-RPC protocol helpers and constants
@@ -137,6 +138,9 @@ export type {
JsonRpcMessage,
JsonRpcErrorCode,
PluginRpcErrorCode,
PluginInvocationScope,
PluginInvocationContext,
WorkerHostCallContext,
InitializeParams,
InitializeResult,
ConfigChangedParams,
@@ -218,6 +222,17 @@ export type {
PluginIssueSubtree,
PluginIssueSummariesClient,
PluginAgentsClient,
PluginAccessClient,
PluginAccessMembersClient,
PluginAccessInvitesClient,
PluginAccessMember,
PluginAccessInvite,
PluginAuthorizationClient,
PluginAuthorizationPolicySummary,
PluginAuthorizationPolicyRecord,
PluginAssignmentPreviewInput,
PluginAuthorizationDecisionResult,
PluginAuthorizationAuditEntry,
PluginAgentSessionsClient,
AgentSession,
AgentSessionEvent,
@@ -253,7 +268,12 @@ export type {
IssueDocumentSummary,
Agent,
Goal,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
PluginDatabaseClient,
HumanCompanyMembershipRole,
MembershipStatus,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
@@ -353,6 +373,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_STATE_SCOPE_KINDS,
PLUGIN_JOB_STATUSES,
PLUGIN_JOB_RUN_STATUSES,
@@ -360,4 +381,9 @@ export {
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
PERMISSION_KEYS,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
MEMBERSHIP_STATUSES,
PRINCIPAL_TYPES,
} from "@paperclipai/shared";
+156
View File
@@ -39,6 +39,7 @@ import type {
Agent,
Goal,
PluginLocalFolderDeclaration,
PrincipalPermissionGrant,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -57,6 +58,13 @@ import type {
ToolResult,
PluginLocalFolderListing,
PluginLocalFolderStatus,
PluginAccessInvite,
PluginAccessMember,
PluginAssignmentPreviewInput,
PluginAuthorizationAuditEntry,
PluginAuthorizationDecisionResult,
PluginAuthorizationPolicyRecord,
PluginAuthorizationPolicySummary,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@@ -96,6 +104,14 @@ export interface JsonRpcRequest<
readonly method: TMethod;
/** Structured parameters for the method call. */
readonly params: TParams;
/**
* Host-issued metadata for the top-level plugin invocation that is currently
* executing. The worker treats this as opaque and echoes only the id on
* worker→host calls made from the same async execution context.
*/
readonly paperclipInvocation?: PluginInvocationContext;
/** Opaque top-level invocation id echoed by worker→host requests. */
readonly paperclipInvocationId?: string;
}
/**
@@ -156,6 +172,13 @@ export interface JsonRpcNotification<
readonly method: TMethod;
/** Structured parameters for the notification. */
readonly params: TParams;
/**
* Host-issued metadata for host→worker push notifications such as events.
* Worker→host notifications echo only `paperclipInvocationId`.
*/
readonly paperclipInvocation?: PluginInvocationContext;
/** Opaque top-level invocation id echoed by worker→host notifications. */
readonly paperclipInvocationId?: string;
}
/**
@@ -217,6 +240,36 @@ export const PLUGIN_RPC_ERROR_CODES = {
export type PluginRpcErrorCode =
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
// ---------------------------------------------------------------------------
// Invocation scope metadata
// ---------------------------------------------------------------------------
/**
* Company scope attached by the host to one top-level plugin invocation.
* Absence of this metadata means the invocation is instance/global scoped.
*/
export interface PluginInvocationScope {
companyId: string;
}
/**
* Opaque invocation metadata generated by the host. Workers must not derive or
* mutate this. They only echo the id on nested worker→host RPC calls.
*/
export interface PluginInvocationContext {
id: string;
scope: PluginInvocationScope;
}
/**
* Context provided to host-side worker→host handlers after the worker echoes a
* host-issued invocation id.
*/
export interface WorkerHostCallContext {
invocationScope?: PluginInvocationScope | null;
invalidInvocationScope?: boolean;
}
// ---------------------------------------------------------------------------
// Host → Worker Method Signatures (§13 Host-Worker Protocol)
// ---------------------------------------------------------------------------
@@ -302,6 +355,8 @@ export interface RunJobParams {
export interface GetDataParams {
/** Plugin-defined data key (e.g. `"sync-health"`). */
key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Context and query parameters from the UI. */
params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */
@@ -316,6 +371,8 @@ export interface GetDataParams {
export interface PerformActionParams {
/** Plugin-defined action key (e.g. `"resync"`). */
key: string;
/** Host-authorized active company scope, when this bridge call is company-scoped. */
companyId?: string | null;
/** Action parameters from the UI. */
params: Record<string, unknown>;
/** Optional launcher/container metadata from the host render environment. */
@@ -1128,6 +1185,105 @@ export interface WorkerToHostMethods {
},
result: Goal,
];
// Access
"access.members.list": [
params: { companyId: string; includeArchived?: boolean },
result: PluginAccessMember[],
];
"access.members.get": [
params: { memberId: string; companyId: string },
result: PluginAccessMember | null,
];
"access.members.update": [
params: {
memberId: string;
companyId: string;
patch: {
membershipRole?: string | null;
status?: "pending" | "active" | "suspended";
};
},
result: PluginAccessMember,
];
"access.invites.list": [
params: {
companyId: string;
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
},
result: { invites: PluginAccessInvite[]; nextOffset: number | null },
];
"access.invites.create": [
params: {
companyId: string;
allowedJoinTypes?: "human" | "agent" | "both";
humanRole?: string | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
},
result: PluginAccessInvite & { token: string },
];
"access.invites.revoke": [
params: { inviteId: string; companyId: string },
result: PluginAccessInvite,
];
// Authorization
"authorization.grants.list": [
params: { companyId: string; principalType?: string; principalId?: string },
result: PrincipalPermissionGrant[],
];
"authorization.grants.set": [
params: {
companyId: string;
principalType: string;
principalId: string;
grants: Array<{ permissionKey: string; scope?: Record<string, unknown> | null }>;
grantedByUserId?: string | null;
},
result: PrincipalPermissionGrant[],
];
"authorization.policies.summary": [
params: { companyId: string },
result: PluginAuthorizationPolicySummary,
];
"authorization.policies.get": [
params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string },
result: PluginAuthorizationPolicyRecord | null,
];
"authorization.policies.update": [
params: {
companyId: string;
resourceType: "company" | "agent" | "project" | "issue";
resourceId: string;
policy: Record<string, unknown> | null;
},
result: PluginAuthorizationPolicyRecord,
];
"authorization.policies.previewAssignment": [
params: PluginAssignmentPreviewInput,
result: PluginAuthorizationDecisionResult,
];
"authorization.policies.explainAssignment": [
params: PluginAssignmentPreviewInput,
result: PluginAuthorizationDecisionResult,
];
"authorization.audit.search": [
params: {
companyId: string;
action?: string;
actorType?: string;
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
},
result: PluginAuthorizationAuditEntry[],
];
}
/** Union of all worker→host method names. */
+196 -1
View File
@@ -38,6 +38,10 @@ import type {
AgentSessionEvent,
PluginLocalFolderEntry,
PluginLocalFolderStatus,
PluginAccessMember,
PrincipalPermissionGrant,
PermissionKey,
PrincipalType,
} from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
@@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
@@ -83,6 +87,8 @@ export interface TestHarness {
goals?: Goal[];
projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
accessMembers?: PluginAccessMember[];
principalGrants?: PrincipalPermissionGrant[];
}): void;
setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */
@@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const issueDocuments = new Map<string, IssueDocument>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const accessMembers = new Map<string, PluginAccessMember>();
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
return `${companyId}:${principalType}:${principalId}`;
}
function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
}
function setPrincipalGrants(
companyId: string,
principalType: PrincipalType,
principalId: string,
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>,
) {
const stamped = grants.map((grant) => ({
principalType,
principalId,
permissionKey: grant.permissionKey,
scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
})) as PrincipalPermissionGrant[];
principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
const member = [...accessMembers.values()].find(
(entry) =>
entry.companyId === companyId
&& entry.principalType === principalType
&& entry.principalId === principalId,
);
if (member) {
accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
}
return stamped;
}
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
@@ -1983,6 +2022,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return updated;
},
},
access: {
members: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.members.read");
const cid = requireCompanyId(input.companyId);
const includeArchived = input.includeArchived === true;
return [...accessMembers.values()]
.filter((member) => member.companyId === cid)
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
.map((member) => ({
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
}));
},
async get(memberId, companyId) {
requireCapability(manifest, capabilitySet, "access.members.read");
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) return null;
return {
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
};
},
async update(memberId, patch, companyId) {
requireCapability(manifest, capabilitySet, "access.members.write");
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) {
throw new Error(`Membership not found: ${memberId}`);
}
const updated: PluginAccessMember = {
...member,
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
status: patch.status === undefined ? member.status : patch.status,
updatedAt: new Date().toISOString(),
};
accessMembers.set(memberId, updated);
return {
...updated,
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
};
},
},
invites: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.invites.read");
requireCompanyId(input.companyId);
return { invites: [], nextOffset: null };
},
async create(input) {
requireCapability(manifest, capabilitySet, "access.invites.write");
requireCompanyId(input.companyId);
throw new Error("Invite creation is not implemented in the plugin test harness");
},
async revoke(inviteId, companyId) {
requireCapability(manifest, capabilitySet, "access.invites.write");
requireCompanyId(companyId);
throw new Error(`Invite not found: ${inviteId}`);
},
},
},
authorization: {
grants: {
async list(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.read");
const cid = requireCompanyId(input.companyId);
if (input.principalType && input.principalId) {
return getPrincipalGrants(cid, input.principalType, input.principalId);
}
const out: PrincipalPermissionGrant[] = [];
for (const [key, grants] of principalGrants.entries()) {
if (!key.startsWith(`${cid}:`)) continue;
for (const grant of grants) {
if (input.principalType && grant.principalType !== input.principalType) continue;
if (input.principalId && grant.principalId !== input.principalId) continue;
out.push(grant);
}
}
return out;
},
async set(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.write");
const cid = requireCompanyId(input.companyId);
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
},
},
policies: {
async summary(companyId) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
const cid = requireCompanyId(companyId);
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
let grantCount = 0;
for (const [key, grants] of principalGrants.entries()) {
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
}
return {
companyId: cid,
permissionsMode: "simple",
memberCount: members.length,
activeMemberCount: members.filter((member) => member.status === "active").length,
grantCount,
advancedPolicyAvailable: false,
};
},
async get(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return null;
},
async update(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.write");
const cid = requireCompanyId(input.companyId);
return {
companyId: cid,
resourceType: input.resourceType,
resourceId: input.resourceId,
policy: input.policy,
updatedAt: new Date().toISOString(),
};
},
async previewAssignment(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return {
allowed: true,
action: "issue.assign",
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
reason: "simple_mode",
};
},
async explainAssignment(input) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
requireCompanyId(input.companyId);
return {
allowed: true,
action: "issue.assign",
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
reason: "simple_mode",
};
},
},
audit: {
async search(input) {
requireCapability(manifest, capabilitySet, "authorization.audit.read");
requireCompanyId(input.companyId);
return [];
},
},
},
data: {
register(key, handler) {
dataHandlers.set(key, handler);
@@ -2065,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaces.set(row.projectId, list);
}
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
for (const row of input.principalGrants ?? []) {
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
list.push(row);
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
}
},
setConfig(config) {
currentConfig = { ...config };
+181
View File
@@ -39,6 +39,12 @@ import type {
RoutineRun,
Agent,
Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -120,6 +126,12 @@ export type {
IssueSurfaceVisibility,
Agent,
Goal,
HumanCompanyMembershipRole,
InviteJoinType,
MembershipStatus,
PermissionKey,
PrincipalPermissionGrant,
PrincipalType,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -1576,6 +1588,169 @@ export interface PluginGoalsClient {
): Promise<Goal>;
}
// ---------------------------------------------------------------------------
// Access and Authorization
// ---------------------------------------------------------------------------
export interface PluginAccessMember {
id: string;
companyId: string;
principalType: PrincipalType;
principalId: string;
status: MembershipStatus;
membershipRole: string | null;
grants: PrincipalPermissionGrant[];
createdAt: Date | string;
updatedAt: Date | string;
}
export interface PluginAccessInvite {
id: string;
companyId: string | null;
inviteType: string;
allowedJoinTypes: InviteJoinType;
defaultsPayload: Record<string, unknown> | null;
expiresAt: Date | string;
invitedByUserId: string | null;
revokedAt: Date | string | null;
acceptedAt: Date | string | null;
createdAt: Date | string;
updatedAt: Date | string;
state: "active" | "revoked" | "accepted" | "expired";
}
export interface PluginAccessMembersClient {
list(input: { companyId: string; includeArchived?: boolean }): Promise<PluginAccessMember[]>;
get(memberId: string, companyId: string): Promise<PluginAccessMember | null>;
update(
memberId: string,
patch: {
membershipRole?: HumanCompanyMembershipRole | null;
status?: Extract<MembershipStatus, "pending" | "active" | "suspended">;
},
companyId: string,
): Promise<PluginAccessMember>;
}
export interface PluginAccessInvitesClient {
list(input: {
companyId: string;
state?: PluginAccessInvite["state"];
limit?: number;
offset?: number;
}): Promise<{ invites: PluginAccessInvite[]; nextOffset: number | null }>;
create(input: {
companyId: string;
allowedJoinTypes?: InviteJoinType;
humanRole?: HumanCompanyMembershipRole | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}): Promise<PluginAccessInvite & { token: string }>;
revoke(inviteId: string, companyId: string): Promise<PluginAccessInvite>;
}
export interface PluginAccessClient {
/** Read and update company memberships. Requires `access.members.*`. */
members: PluginAccessMembersClient;
/** Read, create, and revoke company invites. Requires `access.invites.*`. */
invites: PluginAccessInvitesClient;
}
export interface PluginAuthorizationPolicySummary {
companyId: string;
permissionsMode: "simple";
memberCount: number;
activeMemberCount: number;
grantCount: number;
advancedPolicyAvailable: false;
}
export interface PluginAuthorizationPolicyRecord {
resourceType: "company" | "agent" | "project" | "issue";
resourceId: string;
companyId: string;
policy: Record<string, unknown> | null;
updatedAt: Date | string | null;
}
export interface PluginAssignmentPreviewInput {
companyId: string;
actor:
| { type: "board"; userId?: string | null; companyIds?: string[]; isInstanceAdmin?: boolean }
| { type: "agent"; agentId: string; companyId: string };
target: {
issueId?: string | null;
projectId?: string | null;
parentIssueId?: string | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
status?: string | null;
};
}
export interface PluginAuthorizationDecisionResult {
allowed: boolean;
action: string;
explanation: string;
reason: string;
grant?: {
principalType: PrincipalType;
principalId: string;
permissionKey: PermissionKey;
scope: Record<string, unknown> | null;
};
}
export interface PluginAuthorizationAuditEntry {
id: string;
companyId: string;
actorType: string;
actorId: string;
action: string;
entityType: string;
entityId: string;
details: Record<string, unknown> | null;
createdAt: Date | string;
}
export interface PluginAuthorizationClient {
grants: {
list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise<PrincipalPermissionGrant[]>;
set(input: {
companyId: string;
principalType: PrincipalType;
principalId: string;
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>;
grantedByUserId?: string | null;
}): Promise<PrincipalPermissionGrant[]>;
};
policies: {
summary(companyId: string): Promise<PluginAuthorizationPolicySummary>;
get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise<PluginAuthorizationPolicyRecord | null>;
update(input: {
companyId: string;
resourceType: PluginAuthorizationPolicyRecord["resourceType"];
resourceId: string;
policy: Record<string, unknown> | null;
}): Promise<PluginAuthorizationPolicyRecord>;
previewAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
explainAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
};
audit: {
search(input: {
companyId: string;
action?: string;
actorType?: string;
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
}): Promise<PluginAuthorizationAuditEntry[]>;
};
}
// ---------------------------------------------------------------------------
// Streaming (worker → UI push channel)
// ---------------------------------------------------------------------------
@@ -1716,6 +1891,12 @@ export interface PluginContext {
/** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
goals: PluginGoalsClient;
/** Read and manage access memberships and invites. Requires `access.*` capabilities. */
access: PluginAccessClient;
/** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */
authorization: PluginAuthorizationClient;
/** Register getData handlers for the plugin's UI components. */
data: PluginDataClient;
+1
View File
@@ -146,6 +146,7 @@ export type {
// Slot component prop interfaces
export type {
PluginPageProps,
PluginCompanySettingsPageProps,
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
+12
View File
@@ -229,6 +229,18 @@ export interface PluginPageProps {
context: PluginHostContext;
}
/**
* Props passed to a plugin company settings page component.
*
* A company settings page is mounted at
* `/:companyPrefix/company/settings/:routePath` and always receives the active
* company id and prefix when available.
*/
export interface PluginCompanySettingsPageProps {
/** The current host context, including company id and prefix. */
context: PluginHostContext;
}
/**
* Props passed to a plugin dashboard widget component.
*
+115 -15
View File
@@ -35,6 +35,7 @@
*/
import fs from "node:fs";
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { fileURLToPath } from "node:url";
@@ -66,6 +67,7 @@ import type {
} from "./types.js";
import type {
JsonRpcId,
JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse,
InitializeParams,
@@ -85,6 +87,7 @@ import type {
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentProbeParams,
PluginInvocationContext,
WorkerToHostMethodName,
WorkerToHostMethods,
} from "./protocol.js";
@@ -279,6 +282,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
let manifest: PaperclipPluginManifestV1 | null = null;
let currentConfig: Record<string, unknown> = {};
let databaseNamespace: string | null = null;
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
// Plugin handler registrations (populated during setup())
const eventHandlers: EventRegistration[] = [];
@@ -365,7 +369,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
try {
const request = createRequest(method, params, id);
const activeInvocation = invocationContextStorage.getStore();
const request = {
...createRequest(method, params, id),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
};
sendMessage(request);
} catch (err) {
settle(reject, err instanceof Error ? err : new Error(String(err)));
@@ -378,7 +386,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
*/
function notifyHost(method: string, params: unknown): void {
try {
sendMessage(createNotification(method, params));
const activeInvocation = invocationContextStorage.getStore();
sendMessage({
...createNotification(method, params),
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
});
} catch {
// Swallow — the host may have closed stdin
}
@@ -1086,6 +1098,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
access: {
members: {
async list(input) {
return callHost("access.members.list", {
companyId: input.companyId,
includeArchived: input.includeArchived,
});
},
async get(memberId: string, companyId: string) {
return callHost("access.members.get", { memberId, companyId });
},
async update(memberId: string, patch, companyId: string) {
return callHost("access.members.update", { memberId, patch, companyId });
},
},
invites: {
async list(input) {
return callHost("access.invites.list", {
companyId: input.companyId,
state: input.state,
limit: input.limit,
offset: input.offset,
});
},
async create(input) {
return callHost("access.invites.create", {
companyId: input.companyId,
allowedJoinTypes: input.allowedJoinTypes,
humanRole: input.humanRole,
defaultsPayload: input.defaultsPayload,
agentMessage: input.agentMessage,
});
},
async revoke(inviteId: string, companyId: string) {
return callHost("access.invites.revoke", { inviteId, companyId });
},
},
},
authorization: {
grants: {
async list(input) {
return callHost("authorization.grants.list", input);
},
async set(input) {
return callHost("authorization.grants.set", input);
},
},
policies: {
async summary(companyId: string) {
return callHost("authorization.policies.summary", { companyId });
},
async get(input) {
return callHost("authorization.policies.get", input);
},
async update(input) {
return callHost("authorization.policies.update", input);
},
async previewAssignment(input) {
return callHost("authorization.policies.previewAssignment", input);
},
async explainAssignment(input) {
return callHost("authorization.policies.explainAssignment", input);
},
},
audit: {
async search(input) {
return callHost("authorization.audit.search", input);
},
},
},
data: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
dataHandlers.set(key, handler);
@@ -1175,7 +1266,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
const { id, method, params } = request;
try {
const result = await dispatchMethod(method, params);
const invoke = () => dispatchMethod(method, params);
const result = request.paperclipInvocation
? await invocationContextStorage.run(request.paperclipInvocation, invoke)
: await invoke();
sendMessage(createSuccessResponse(id, result ?? null));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -1413,11 +1507,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No data handler registered for key "${params.key}"`);
}
return handler(
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment },
);
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
@@ -1425,11 +1519,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!handler) {
throw new Error(`No action handler registered for key "${params.key}"`);
}
return handler(
params.renderEnvironment === undefined
? params.params
: { ...params.params, renderEnvironment: params.renderEnvironment },
);
return handler({
...params.params,
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
});
}
async function handleExecuteTool(params: ExecuteToolParams): Promise<ToolResult> {
@@ -1597,14 +1691,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
});
} else if (isJsonRpcNotification(message)) {
// Dispatch host→worker push notifications
const notif = message as { method: string; params?: unknown };
const notif = message as JsonRpcNotification & { method: string; params?: unknown };
const runNotification = (fn: () => void | Promise<void>) => {
if (notif.paperclipInvocation) {
return invocationContextStorage.run(notif.paperclipInvocation, fn);
}
return fn();
};
if (notif.method === "agents.sessions.event" && notif.params) {
const event = notif.params as AgentSessionEvent;
const cb = sessionEventCallbacks.get(event.sessionId);
if (cb) cb(event);
} else if (notif.method === "onEvent" && notif.params) {
// Plugin event bus notifications — dispatch to registered event handlers
handleOnEvent(notif.params as OnEventParams).catch((err) => {
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
notifyHost("log", {
level: "error",
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
@@ -0,0 +1,166 @@
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";
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);
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();
});
});
@@ -1,11 +1,26 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { isWorkerEntrypoint } from "../src/worker-rpc-host.js";
import { definePlugin } from "../src/define-plugin.js";
import {
createRequest,
createErrorResponse,
createSuccessResponse,
isJsonRpcRequest,
isJsonRpcResponse,
parseMessage,
PLUGIN_RPC_ERROR_CODES,
serializeMessage,
type JsonRpcResponse,
type PluginInvocationContext,
} from "../src/protocol.js";
import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js";
describe("isWorkerEntrypoint", () => {
const tempRoots: string[] = [];
@@ -55,3 +70,145 @@ describe("isWorkerEntrypoint", () => {
).toBe(false);
});
});
describe("worker invocation scope propagation", () => {
it("keeps overlapping company scopes local to each getData invocation", async () => {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
const nestedInvocationIds: string[] = [];
const invocationCompanies = new Map([
["invocation-a", "company-a"],
["invocation-b", "company-b"],
]);
let releaseCompanyA: (() => void) | null = null;
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("probe", async (params) => {
if (params.label === "a") {
await new Promise<void>((resolve) => {
releaseCompanyA = resolve;
});
}
const company = await ctx.companies.get(String(params.requestedCompanyId));
return { label: params.label, company };
});
},
});
const worker = startWorkerRpcHost({
plugin,
stdin: hostToWorker,
stdout: workerToHost,
});
function callWorker(method: string, params: unknown, invocation?: PluginInvocationContext) {
const id = `host-${nextRequestId++}`;
const request = {
...createRequest(method, params, id),
...(invocation ? { paperclipInvocation: invocation } : {}),
};
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(request));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
return;
}
if (!isJsonRpcRequest(message)) return;
if (message.method !== "companies.get") return;
const invocationId = (message as { paperclipInvocationId?: string }).paperclipInvocationId ?? "";
const requestedCompanyId = (message.params as { companyId?: string }).companyId;
const allowedCompanyId = invocationCompanies.get(invocationId);
nestedInvocationIds.push(invocationId);
if (requestedCompanyId !== allowedCompanyId) {
hostToWorker.write(serializeMessage(createErrorResponse(
message.id,
PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
`requested company "${requestedCompanyId}" but invocation "${invocationId}" is scoped to "${allowedCompanyId}"`,
)));
return;
}
hostToWorker.write(serializeMessage(createSuccessResponse(message.id, {
id: requestedCompanyId,
})));
if (invocationId === "invocation-b") {
releaseCompanyA?.();
}
});
try {
await callWorker("initialize", {
manifest: {
id: "paperclip.scope-test",
apiVersion: 1,
version: "1.0.0",
displayName: "Scope test",
description: "Scope test",
author: "Paperclip",
categories: ["automation"],
capabilities: ["companies.read"],
entrypoints: { worker: "dist/worker.js" },
},
config: {},
instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
apiVersion: 1,
});
const companyARequest = callWorker(
"getData",
{
key: "probe",
companyId: "company-a",
params: { label: "a", requestedCompanyId: "company-b" },
},
{ id: "invocation-a", scope: { companyId: "company-a" } },
);
const companyAExpectation = expect(companyARequest).rejects.toThrow(
/requested company "company-b"/,
);
const companyBRequest = callWorker(
"getData",
{
key: "probe",
companyId: "company-b",
params: { label: "b", requestedCompanyId: "company-b" },
},
{ id: "invocation-b", scope: { companyId: "company-b" } },
);
await expect(companyBRequest).resolves.toEqual({
label: "b",
company: { id: "company-b" },
});
await companyAExpectation;
expect(nestedInvocationIds).toEqual(["invocation-b", "invocation-a"]);
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
});
});
+25
View File
@@ -739,6 +739,11 @@ export const PLUGIN_CAPABILITIES = [
"activity.read",
"costs.read",
"issues.orchestration.read",
"access.members.read",
"access.invites.read",
"authorization.grants.read",
"authorization.policies.read",
"authorization.audit.read",
"database.namespace.read",
// Data Write
"issues.create",
@@ -756,6 +761,10 @@ export const PLUGIN_CAPABILITIES = [
"agents.resume",
"agents.invoke",
"agents.managed",
"access.members.write",
"access.invites.write",
"authorization.grants.write",
"authorization.policies.write",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
@@ -857,6 +866,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"commentAnnotation",
"commentContextMenuItem",
"settingsPage",
"companySettingsPage",
] as const;
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
@@ -887,6 +897,21 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
export type PluginReservedCompanyRouteSegment =
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
/**
* Reserved route segments under `/:companyPrefix/company/settings/...` that
* plugin company settings pages may not claim.
*/
export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [
"general",
"environments",
"access",
"members",
"invites",
"secrets",
] as const;
export type PluginReservedCompanySettingsRouteSegment =
(typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number];
/**
* Launcher placement zones describe where a plugin-owned launcher can appear
* in the host UI. These are intentionally aligned with current slot surfaces
+2
View File
@@ -111,6 +111,7 @@ export {
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
@@ -226,6 +227,7 @@ export {
type PluginCapability,
type PluginUiSlotType,
type PluginUiSlotEntityType,
type PluginReservedCompanySettingsRouteSegment,
type PluginLauncherPlacementZone,
type PluginLauncherAction,
type PluginLauncherBounds,
+1 -1
View File
@@ -58,7 +58,7 @@ export interface AgentInstructionsBundle {
export interface AgentAccessState {
canAssignTasks: boolean;
taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none";
taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none";
membership: CompanyMembership | null;
grants: PrincipalPermissionGrant[];
}
+4 -1
View File
@@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/**
* Optional company-scoped route segment for page and routeSidebar slots.
* Optional company-scoped route segment for page, routeSidebar, and
* companySettingsPage slots.
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
* For companySettingsPage, `permissions` becomes
* `/:companyPrefix/company/settings/permissions`.
*/
routePath?: string;
/**
@@ -8,6 +8,37 @@ describe("plugin capability constants", () => {
});
});
describe("plugin manifest validators", () => {
it("accepts existing-style plugins that do not request access or authorization capabilities", () => {
const parsed = pluginManifestV1Schema.parse({
id: "paperclip.compat-dashboard",
apiVersion: 1,
version: "0.1.0",
displayName: "Compat Dashboard",
description: "Dashboard-only plugin without access or authorization host APIs.",
author: "Paperclip",
categories: ["ui"],
capabilities: ["ui.dashboardWidget.register"],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui.js",
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "compat-dashboard",
displayName: "Compat Dashboard",
exportName: "CompatDashboard",
},
],
},
});
expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]);
});
});
describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({
@@ -128,4 +159,30 @@ describe("plugin UI slot validators", () => {
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
it("accepts company settings page slots with a non-core settings route", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "companySettingsPage",
id: "permissions-settings",
displayName: "Permissions",
exportName: "PermissionsSettingsPage",
routePath: "permissions",
});
expect(parsed.routePath).toBe("permissions");
});
it("prevents company settings page slots from shadowing core settings routes", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "companySettingsPage",
id: "access-settings",
displayName: "Access",
exportName: "AccessSettingsPage",
routePath: "access",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
});
+21 -2
View File
@@ -6,6 +6,7 @@ import {
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
@@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"],
});
}
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routePath is only supported for page and routeSidebar slots",
message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots",
path: ["routePath"],
});
}
@@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"],
});
}
if (value.type === "companySettingsPage" && !value.routePath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "companySettingsPage slots require routePath",
path: ["routePath"],
});
}
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["routePath"],
});
}
if (
value.type === "companySettingsPage"
&& value.routePath
&& PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number])
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `company settings routePath "${value.routePath}" is reserved by the host`,
path: ["routePath"],
});
}
});
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;