mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Merge remote-tracking branch 'origin/master' into codex/pap-9980-plugin-runtime-hardening
# Conflicts: # packages/plugins/sdk/src/host-client-factory.ts # packages/plugins/sdk/src/protocol.ts # packages/plugins/sdk/src/worker-rpc-host.ts # packages/plugins/sdk/tests/host-client-factory.test.ts # packages/plugins/sdk/tests/worker-rpc-host.test.ts # server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs # server/src/__tests__/plugin-worker-manager.test.ts # server/src/routes/plugins.ts # server/src/services/plugin-worker-manager.ts
This commit is contained in:
commit
4161c63bc0
101 files changed
+6609
-605
No files matched your search
@@ -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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,17 +74,15 @@ export class CapabilityDeniedError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a worker→host call tries to escape the company scope attached to
|
||||
* the current host invocation.
|
||||
* 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.INVOCATION_SCOPE_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, requestedCompanyId: string, allowedCompanyId: string) {
|
||||
super(
|
||||
`Plugin "${pluginId}" cannot call "${method}" for company "${requestedCompanyId}" during invocation scoped to company "${allowedCompanyId}"`,
|
||||
);
|
||||
constructor(pluginId: string, method: string, message: string) {
|
||||
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,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]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,17 +327,9 @@ export interface HostClientFactoryOptions {
|
||||
*/
|
||||
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
context?: HostHandlerContext,
|
||||
context?: WorkerHostCallContext,
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
export interface HostInvocationScope {
|
||||
companyId?: string | null;
|
||||
}
|
||||
|
||||
export interface HostHandlerContext {
|
||||
invocationScope?: HostInvocationScope | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete map of all worker→host method handlers.
|
||||
*
|
||||
@@ -455,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",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -485,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.
|
||||
@@ -498,43 +603,6 @@ export function createHostClientHandlers(
|
||||
throw new CapabilityDeniedError(pluginId, method, required);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function invocationCompanyId(context: HostHandlerContext | undefined): string | null {
|
||||
return nonEmptyString(context?.invocationScope?.companyId);
|
||||
}
|
||||
|
||||
function requestedCompanyId(params: unknown): string | null {
|
||||
if (!params || typeof params !== "object") return null;
|
||||
const record = params as Record<string, unknown>;
|
||||
const directCompanyId = nonEmptyString(record.companyId);
|
||||
if (directCompanyId) return directCompanyId;
|
||||
if (record.scopeKind === "company") return nonEmptyString(record.scopeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertInvocationCompanyScope(
|
||||
method: WorkerToHostMethodName,
|
||||
params: unknown,
|
||||
context: HostHandlerContext | undefined,
|
||||
): void {
|
||||
const allowedCompanyId = invocationCompanyId(context);
|
||||
if (!allowedCompanyId) return;
|
||||
const companyId = requestedCompanyId(params);
|
||||
if (!companyId || companyId === allowedCompanyId) return;
|
||||
throw new InvocationScopeDeniedError(pluginId, method, companyId, allowedCompanyId);
|
||||
}
|
||||
|
||||
function filterCompaniesToInvocationScope<T extends { id: string }>(
|
||||
rows: T[],
|
||||
context: HostHandlerContext | undefined,
|
||||
): T[] {
|
||||
const companyId = invocationCompanyId(context);
|
||||
return companyId ? rows.filter((row) => row.id === companyId) : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a capability-gated proxy handler for a method.
|
||||
*
|
||||
@@ -546,9 +614,9 @@ export function createHostClientHandlers(
|
||||
method: M,
|
||||
handler: HostHandler<M>,
|
||||
): HostHandler<M> {
|
||||
return async (params: WorkerToHostMethods[M][0], context?: HostHandlerContext) => {
|
||||
return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
|
||||
requireCapability(method);
|
||||
assertInvocationCompanyScope(method, params, context);
|
||||
requireInvocationCompanyScope(method, params, context);
|
||||
return handler(params, context);
|
||||
};
|
||||
}
|
||||
@@ -654,7 +722,12 @@ export function createHostClientHandlers(
|
||||
|
||||
// Companies
|
||||
"companies.list": gated("companies.list", async (params, context) => {
|
||||
return filterCompaniesToInvocationScope(await services.companies.list(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);
|
||||
@@ -834,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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -129,8 +129,6 @@ export type {
|
||||
// JSON-RPC protocol types
|
||||
export type {
|
||||
JsonRpcId,
|
||||
JsonRpcInvocationScope,
|
||||
JsonRpcInvocationContext,
|
||||
JsonRpcRequest,
|
||||
JsonRpcSuccessResponse,
|
||||
JsonRpcError,
|
||||
@@ -140,6 +138,9 @@ export type {
|
||||
JsonRpcMessage,
|
||||
JsonRpcErrorCode,
|
||||
PluginRpcErrorCode,
|
||||
PluginInvocationScope,
|
||||
PluginInvocationContext,
|
||||
WorkerHostCallContext,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ConfigChangedParams,
|
||||
@@ -224,6 +225,17 @@ export type {
|
||||
PluginIssueSubtree,
|
||||
PluginIssueSummariesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAccessClient,
|
||||
PluginAccessMembersClient,
|
||||
PluginAccessInvitesClient,
|
||||
PluginAccessMember,
|
||||
PluginAccessInvite,
|
||||
PluginAuthorizationClient,
|
||||
PluginAuthorizationPolicySummary,
|
||||
PluginAuthorizationPolicyRecord,
|
||||
PluginAssignmentPreviewInput,
|
||||
PluginAuthorizationDecisionResult,
|
||||
PluginAuthorizationAuditEntry,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
@@ -259,7 +271,12 @@ export type {
|
||||
IssueDocumentSummary,
|
||||
Agent,
|
||||
Goal,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
PluginDatabaseClient,
|
||||
HumanCompanyMembershipRole,
|
||||
MembershipStatus,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
@@ -359,6 +376,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,
|
||||
@@ -366,4 +384,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,
|
||||
@@ -79,19 +87,6 @@ export const JSONRPC_VERSION = "2.0" as const;
|
||||
*/
|
||||
export type JsonRpcId = string | number;
|
||||
|
||||
/**
|
||||
* Host-owned scope attached to a host→worker invocation. Workers may echo the
|
||||
* invocation id on nested worker→host calls, but they never author this scope.
|
||||
*/
|
||||
export interface JsonRpcInvocationScope {
|
||||
readonly companyId?: string | null;
|
||||
}
|
||||
|
||||
export interface JsonRpcInvocationContext {
|
||||
readonly id: string;
|
||||
readonly scope: JsonRpcInvocationScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON-RPC 2.0 request message.
|
||||
*
|
||||
@@ -109,9 +104,13 @@ export interface JsonRpcRequest<
|
||||
readonly method: TMethod;
|
||||
/** Structured parameters for the method call. */
|
||||
readonly params: TParams;
|
||||
/** Host-owned invocation metadata sent only from host→worker requests. */
|
||||
readonly paperclipInvocation?: JsonRpcInvocationContext;
|
||||
/** Invocation id echoed by SDK worker→host calls made inside that request. */
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -173,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +242,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -321,6 +357,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. */
|
||||
@@ -357,6 +395,8 @@ export interface PluginPerformActionContext {
|
||||
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>;
|
||||
/** Authenticated actor context resolved by the host, never by caller params. */
|
||||
@@ -1171,6 +1211,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,
|
||||
@@ -89,7 +93,7 @@ export interface TestHarnessPerformActionOptions {
|
||||
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[];
|
||||
@@ -99,6 +103,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. */
|
||||
@@ -460,6 +466,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>();
|
||||
@@ -2041,6 +2080,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);
|
||||
@@ -2123,6 +2312,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";
|
||||
import type { PluginPerformActionContext } from "./protocol.js";
|
||||
|
||||
@@ -121,6 +127,12 @@ export type {
|
||||
IssueSurfaceVisibility,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1580,6 +1592,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1720,6 +1895,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,
|
||||
|
||||
@@ -230,6 +230,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.
|
||||
*
|
||||
|
||||
@@ -67,6 +67,7 @@ import type {
|
||||
} from "./types.js";
|
||||
import type {
|
||||
JsonRpcId,
|
||||
JsonRpcNotification,
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
InitializeParams,
|
||||
@@ -88,6 +89,7 @@ import type {
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginInvocationContext,
|
||||
WorkerToHostMethodName,
|
||||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
@@ -282,6 +284,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[] = [];
|
||||
@@ -307,7 +310,6 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
}>();
|
||||
let nextOutboundId = 1;
|
||||
const MAX_OUTBOUND_ID = Number.MAX_SAFE_INTEGER - 1;
|
||||
const invocationStorage = new AsyncLocalStorage<string | null>();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Outbound messaging (worker → host)
|
||||
@@ -372,9 +374,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
});
|
||||
|
||||
try {
|
||||
const request = createRequest(method, params, id);
|
||||
const invocationId = invocationStorage.getStore();
|
||||
sendMessage(invocationId ? { ...request, paperclipInvocationId: invocationId } : request);
|
||||
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)));
|
||||
}
|
||||
@@ -386,7 +391,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
|
||||
}
|
||||
@@ -1094,6 +1103,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);
|
||||
@@ -1184,33 +1272,25 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
*/
|
||||
async function handleHostRequest(request: JsonRpcRequest): Promise<void> {
|
||||
const { id, method, params } = request;
|
||||
const invocationId = typeof request.paperclipInvocation?.id === "string"
|
||||
? request.paperclipInvocation.id
|
||||
: null;
|
||||
|
||||
const execute = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await dispatchMethod(method, params);
|
||||
sendMessage(createSuccessResponse(id, result ?? null));
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
// Propagate specific error codes from handler errors (e.g.
|
||||
// METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to
|
||||
// WORKER_ERROR for untyped exceptions.
|
||||
const errorCode =
|
||||
typeof (err as any)?.code === "number"
|
||||
? (err as any).code
|
||||
: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR;
|
||||
try {
|
||||
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);
|
||||
// Propagate specific error codes from handler errors (e.g.
|
||||
// METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to
|
||||
// WORKER_ERROR for untyped exceptions.
|
||||
const errorCode =
|
||||
typeof (err as any)?.code === "number"
|
||||
? (err as any).code
|
||||
: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR;
|
||||
|
||||
sendMessage(createErrorResponse(id, errorCode, errorMessage));
|
||||
}
|
||||
};
|
||||
|
||||
if (invocationId) {
|
||||
await invocationStorage.run(invocationId, execute);
|
||||
return;
|
||||
sendMessage(createErrorResponse(id, errorCode, errorMessage));
|
||||
}
|
||||
await execute();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1435,11 +1515,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 }),
|
||||
});
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
@@ -1473,9 +1553,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
throw new Error(`No action handler registered for key "${params.key}"`);
|
||||
}
|
||||
return handler(
|
||||
params.renderEnvironment === undefined
|
||||
? params.params
|
||||
: { ...params.params, renderEnvironment: params.renderEnvironment },
|
||||
{
|
||||
...params.params,
|
||||
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
|
||||
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
|
||||
},
|
||||
actionContextFromParams(params),
|
||||
);
|
||||
}
|
||||
@@ -1645,14 +1727,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)}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -85,4 +86,90 @@ describe("createHostClientHandlers invocation company scope", () => {
|
||||
).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,64 +1,27 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
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 { definePlugin } from "../src/define-plugin.js";
|
||||
import type { JsonRpcResponse } from "../src/protocol.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";
|
||||
|
||||
function createWorkerHarness() {
|
||||
const stdin = new PassThrough();
|
||||
const stdout = new PassThrough();
|
||||
let buffer = "";
|
||||
const messages: JsonRpcResponse[] = [];
|
||||
const waiters = new Map<string | number | null, Array<(message: JsonRpcResponse) => void>>();
|
||||
|
||||
stdout.setEncoding("utf8");
|
||||
stdout.on("data", (chunk) => {
|
||||
buffer += String(chunk);
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line) {
|
||||
const message = JSON.parse(line) as JsonRpcResponse;
|
||||
const waiter = waiters.get(message.id)?.shift();
|
||||
if (waiter) {
|
||||
waiter(message);
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
|
||||
async function request(method: string, params: unknown, id: string | number): Promise<JsonRpcResponse> {
|
||||
const existingIndex = messages.findIndex((message) => message.id === id);
|
||||
if (existingIndex >= 0) {
|
||||
return messages.splice(existingIndex, 1)[0]!;
|
||||
}
|
||||
const response = new Promise<JsonRpcResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), 1_000);
|
||||
const wrappedResolve = (message: JsonRpcResponse) => {
|
||||
clearTimeout(timer);
|
||||
resolve(message);
|
||||
};
|
||||
const existing = waiters.get(id) ?? [];
|
||||
existing.push(wrappedResolve);
|
||||
waiters.set(id, existing);
|
||||
});
|
||||
stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
return response;
|
||||
}
|
||||
|
||||
return { stdin, stdout, request };
|
||||
}
|
||||
|
||||
describe("isWorkerEntrypoint", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
@@ -110,7 +73,11 @@ describe("isWorkerEntrypoint", () => {
|
||||
|
||||
describe("worker performAction context", () => {
|
||||
it("does not derive context companyId from caller params without host actor context", async () => {
|
||||
const harness = createWorkerHarness();
|
||||
const hostToWorker = new PassThrough();
|
||||
const workerToHost = new PassThrough();
|
||||
const hostReadline = createInterface({ input: workerToHost });
|
||||
const pending = new Map<string, (response: JsonRpcResponse) => void>();
|
||||
let nextRequestId = 1;
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.actions.register("inspect", async (params, context) => ({
|
||||
@@ -120,14 +87,36 @@ describe("worker performAction context", () => {
|
||||
}));
|
||||
},
|
||||
});
|
||||
const host = startWorkerRpcHost({
|
||||
const worker = startWorkerRpcHost({
|
||||
plugin,
|
||||
stdin: harness.stdin,
|
||||
stdout: harness.stdout,
|
||||
stdin: hostToWorker,
|
||||
stdout: workerToHost,
|
||||
});
|
||||
|
||||
function callWorker(method: string, params: unknown) {
|
||||
const id = `host-${nextRequestId++}`;
|
||||
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(createRequest(method, params, id)));
|
||||
return result;
|
||||
}
|
||||
|
||||
hostReadline.on("line", (line) => {
|
||||
const message = parseMessage(line);
|
||||
if (!isJsonRpcResponse(message)) return;
|
||||
pending.get(String(message.id))?.(message);
|
||||
pending.delete(String(message.id));
|
||||
});
|
||||
|
||||
try {
|
||||
const initialize = await harness.request("initialize", {
|
||||
await expect(callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.test-worker-context",
|
||||
apiVersion: 1,
|
||||
@@ -141,15 +130,12 @@ describe("worker performAction context", () => {
|
||||
},
|
||||
config: {},
|
||||
databaseNamespace: null,
|
||||
}, 1);
|
||||
expect("result" in initialize ? initialize.result : null).toMatchObject({ ok: true });
|
||||
})).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const response = await harness.request("performAction", {
|
||||
await expect(callWorker("performAction", {
|
||||
key: "inspect",
|
||||
params: { companyId: "spoofed-company" },
|
||||
}, 2);
|
||||
|
||||
expect("result" in response ? response.result : null).toEqual({
|
||||
})).resolves.toEqual({
|
||||
paramsCompanyId: "spoofed-company",
|
||||
actor: {
|
||||
type: "system",
|
||||
@@ -161,7 +147,152 @@ describe("worker performAction context", () => {
|
||||
companyId: null,
|
||||
});
|
||||
} finally {
|
||||
host.stop();
|
||||
worker.stop();
|
||||
hostReadline.close();
|
||||
hostToWorker.destroy();
|
||||
workerToHost.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user