[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:
@@ -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);
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ export type {
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginCompanySettingsPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user