Resolved conflicts: - ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items - ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`) - server plugin-worker-manager.ts: take master's 15min RPC timeout cap - pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.INVOCATION_SCOPE_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, message: string) {
|
||||
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host service interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,6 +194,11 @@ export interface HostServices {
|
||||
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `executionWorkspaces.get`. */
|
||||
executionWorkspaces: {
|
||||
get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise<WorkerToHostMethods["executionWorkspaces.get"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `routines.managed.*`. */
|
||||
routines: {
|
||||
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
|
||||
@@ -252,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]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -287,6 +327,7 @@ export interface HostClientFactoryOptions {
|
||||
*/
|
||||
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
context?: WorkerHostCallContext,
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
/**
|
||||
@@ -368,6 +409,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
"executionWorkspaces.get": "execution.workspaces.read",
|
||||
"projects.managed.get": "projects.managed",
|
||||
"projects.managed.reconcile": "projects.managed",
|
||||
"projects.managed.reset": "projects.managed",
|
||||
@@ -425,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",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -455,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.
|
||||
@@ -479,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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -585,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);
|
||||
@@ -608,6 +749,9 @@ export function createHostClientHandlers(
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
"executionWorkspaces.get": gated("executionWorkspaces.get", async (params) => {
|
||||
return services.executionWorkspaces.get(params);
|
||||
}),
|
||||
"projects.managed.get": gated("projects.managed.get", async (params) => {
|
||||
return services.projects.getManaged(params);
|
||||
}),
|
||||
@@ -763,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
|
||||
@@ -128,6 +129,8 @@ export type {
|
||||
// JSON-RPC protocol types
|
||||
export type {
|
||||
JsonRpcId,
|
||||
JsonRpcInvocationScope,
|
||||
JsonRpcInvocationContext,
|
||||
JsonRpcRequest,
|
||||
JsonRpcSuccessResponse,
|
||||
JsonRpcError,
|
||||
@@ -137,6 +140,9 @@ export type {
|
||||
JsonRpcMessage,
|
||||
JsonRpcErrorCode,
|
||||
PluginRpcErrorCode,
|
||||
PluginInvocationScope,
|
||||
PluginInvocationContext,
|
||||
WorkerHostCallContext,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ConfigChangedParams,
|
||||
@@ -145,6 +151,9 @@ export type {
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
PluginPerformActionActorType,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentDiagnostic,
|
||||
PluginEnvironmentDriverBaseParams,
|
||||
@@ -197,6 +206,7 @@ export type {
|
||||
PluginStateClient,
|
||||
PluginEntitiesClient,
|
||||
PluginProjectsClient,
|
||||
PluginExecutionWorkspacesClient,
|
||||
PluginSkillsClient,
|
||||
PluginCompaniesClient,
|
||||
PluginIssuesClient,
|
||||
@@ -217,6 +227,17 @@ export type {
|
||||
PluginIssueSubtree,
|
||||
PluginIssueSummariesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAccessClient,
|
||||
PluginAccessMembersClient,
|
||||
PluginAccessInvitesClient,
|
||||
PluginAccessMember,
|
||||
PluginAccessInvite,
|
||||
PluginAuthorizationClient,
|
||||
PluginAuthorizationPolicySummary,
|
||||
PluginAuthorizationPolicyRecord,
|
||||
PluginAssignmentPreviewInput,
|
||||
PluginAuthorizationDecisionResult,
|
||||
PluginAuthorizationAuditEntry,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
@@ -244,6 +265,7 @@ export type {
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
@@ -251,7 +273,12 @@ export type {
|
||||
IssueDocumentSummary,
|
||||
Agent,
|
||||
Goal,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
PluginDatabaseClient,
|
||||
HumanCompanyMembershipRole,
|
||||
MembershipStatus,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
@@ -351,6 +378,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,
|
||||
@@ -358,4 +386,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";
|
||||
|
||||
@@ -51,11 +52,19 @@ import type {
|
||||
PluginIssueWakeupBatchResult,
|
||||
PluginIssueWakeupResult,
|
||||
PluginJobContext,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
PluginWorkspace,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginLocalFolderListing,
|
||||
PluginLocalFolderStatus,
|
||||
PluginAccessInvite,
|
||||
PluginAccessMember,
|
||||
PluginAssignmentPreviewInput,
|
||||
PluginAuthorizationAuditEntry,
|
||||
PluginAuthorizationDecisionResult,
|
||||
PluginAuthorizationPolicyRecord,
|
||||
PluginAuthorizationPolicySummary,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginHealthDiagnostics,
|
||||
@@ -78,6 +87,19 @@ 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.
|
||||
*
|
||||
@@ -95,6 +117,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +185,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,6 +246,8 @@ export const PLUGIN_RPC_ERROR_CODES = {
|
||||
TIMEOUT: -32003,
|
||||
/** The worker does not implement the requested optional method. */
|
||||
METHOD_NOT_IMPLEMENTED: -32004,
|
||||
/** The worker→host call attempted to escape the current invocation company scope. */
|
||||
INVOCATION_SCOPE_DENIED: -32005,
|
||||
/** A catch-all for errors that do not fit other categories. */
|
||||
UNKNOWN: -32099,
|
||||
} as const;
|
||||
@@ -216,6 +255,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -301,6 +370,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. */
|
||||
@@ -312,11 +383,37 @@ export interface GetDataParams {
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||
*/
|
||||
export type PluginPerformActionActorType = "user" | "agent" | "system";
|
||||
|
||||
export interface PluginPerformActionActorContext {
|
||||
/** Authenticated principal type resolved by the Paperclip host. */
|
||||
type: PluginPerformActionActorType;
|
||||
/** Authenticated board user id when `type === "user"`, otherwise null. */
|
||||
userId: string | null;
|
||||
/** Authenticated agent id when `type === "agent"`, otherwise null. */
|
||||
agentId: string | null;
|
||||
/** Authenticated heartbeat/run id when available. */
|
||||
runId: string | null;
|
||||
/** Company id authorized by the host bridge for this action, when applicable. */
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPerformActionContext {
|
||||
/** Immutable authenticated actor context supplied by the host. */
|
||||
actor: Readonly<PluginPerformActionActorContext>;
|
||||
/** Convenience alias for `actor.companyId`. */
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
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. */
|
||||
actorContext?: PluginPerformActionActorContext | null;
|
||||
/** Optional launcher/container metadata from the host render environment. */
|
||||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||||
}
|
||||
@@ -792,6 +889,13 @@ export interface WorkerToHostMethods {
|
||||
params: { issueId: string; companyId: string },
|
||||
result: PluginWorkspace | null,
|
||||
];
|
||||
"executionWorkspaces.get": [
|
||||
params: {
|
||||
workspaceId: string;
|
||||
companyId: string;
|
||||
},
|
||||
result: PluginExecutionWorkspaceMetadata | null,
|
||||
];
|
||||
"projects.managed.get": [
|
||||
params: { projectKey: string; companyId: string },
|
||||
result: PluginManagedProjectResolution,
|
||||
@@ -1135,6 +1239,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. */
|
||||
|
||||
@@ -33,10 +33,15 @@ import type {
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderStatus,
|
||||
PluginAccessMember,
|
||||
PrincipalPermissionGrant,
|
||||
PermissionKey,
|
||||
PrincipalType,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
@@ -52,6 +57,8 @@ import type {
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
@@ -69,10 +76,24 @@ export interface TestHarnessLogEntry {
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessPerformActionOptions {
|
||||
/**
|
||||
* Authenticated actor context to expose to the action handler. Omitted fields
|
||||
* default to null, and `type` defaults to `system`.
|
||||
*/
|
||||
actor?: Partial<PluginPerformActionActorContext> | null;
|
||||
/**
|
||||
* Host-authorized company scope. When provided, this is injected into
|
||||
* `params.companyId` so tests match the production bridge's anti-spoofing
|
||||
* behavior.
|
||||
*/
|
||||
companyId?: string | null;
|
||||
}
|
||||
|
||||
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[];
|
||||
@@ -80,6 +101,10 @@ export interface TestHarness {
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
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. */
|
||||
@@ -89,7 +114,11 @@ export interface TestHarness {
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
performAction<T = unknown>(
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
@@ -437,7 +466,41 @@ 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>();
|
||||
const localFolderFiles = new Map<string, string>();
|
||||
|
||||
@@ -448,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<
|
||||
string,
|
||||
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
||||
>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
function localFolderKey(companyId: string, folderKey: string): string {
|
||||
@@ -459,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
return `${localFolderKey(companyId, folderKey)}:${relativePath}`;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
|
||||
return value === "user" || value === "agent" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
function actionContextFor(
|
||||
params: Record<string, unknown>,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): PluginPerformActionContext {
|
||||
const actorInput = options?.actor ?? null;
|
||||
const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId);
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(actorInput?.type),
|
||||
userId: stringOrNull(actorInput?.userId),
|
||||
agentId: stringOrNull(actorInput?.agentId),
|
||||
runId: stringOrNull(actorInput?.runId),
|
||||
companyId,
|
||||
});
|
||||
return Object.freeze({ actor, companyId });
|
||||
}
|
||||
|
||||
function paramsWithHostCompanyScope(
|
||||
params: Record<string, unknown>,
|
||||
context: PluginPerformActionContext,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): Record<string, unknown> {
|
||||
if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) {
|
||||
return context.companyId ? { ...params, companyId: context.companyId } : { ...params };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function normalizeLocalFolderRelativePath(relativePath: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const segment of relativePath.split(/[\\/]+/)) {
|
||||
@@ -975,6 +1076,13 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
},
|
||||
},
|
||||
},
|
||||
executionWorkspaces: {
|
||||
async get(workspaceId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "execution.workspaces.read");
|
||||
const workspace = executionWorkspaces.get(workspaceId);
|
||||
return workspace?.companyId === companyId ? workspace : null;
|
||||
},
|
||||
},
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey, companyId) {
|
||||
@@ -1604,6 +1712,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
createdByUserId: existing?.createdByUserId ?? null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lockedAt: existing?.lockedAt ?? null,
|
||||
lockedByAgentId: existing?.lockedByAgentId ?? null,
|
||||
lockedByUserId: existing?.lockedByUserId ?? null,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
body: input.body,
|
||||
@@ -1969,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);
|
||||
@@ -2045,6 +2306,18 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
for (const row of input.projectWorkspaces ?? []) {
|
||||
const list = projectWorkspaces.get(row.projectId) ?? [];
|
||||
list.push(row);
|
||||
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 };
|
||||
@@ -2087,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
async performAction<T = unknown>(
|
||||
key: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
const context = actionContextFor(params, options);
|
||||
return await handler(paramsWithHostCompanyScope(params, context, options), context) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
|
||||
@@ -39,7 +39,14 @@ import type {
|
||||
RoutineRun,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
import type { PluginPerformActionContext } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports from @paperclipai/shared (plugin authors import from one place)
|
||||
@@ -120,6 +127,12 @@ export type {
|
||||
IssueSurfaceVisibility,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -344,6 +357,12 @@ export interface PluginWorkspace {
|
||||
name: string;
|
||||
/** Absolute filesystem path to the workspace directory. */
|
||||
path: string;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Checkout/ref requested for the workspace, when known. */
|
||||
repoRef: string | null;
|
||||
/** Default comparison ref for workspace tooling, when known. */
|
||||
defaultRef: string | null;
|
||||
/** Whether this is the project's primary workspace. */
|
||||
isPrimary: boolean;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
@@ -352,6 +371,40 @@ export interface PluginWorkspace {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution workspace metadata (read-only via ctx.executionWorkspaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Plugin-safe execution workspace metadata provided by the host. This exposes
|
||||
* the local/repository coordinates plugins need for workspace tooling without
|
||||
* giving the SDK a host-owned diff engine.
|
||||
*/
|
||||
export interface PluginExecutionWorkspaceMetadata {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** UUID of the owning company. */
|
||||
companyId: string;
|
||||
/** UUID of the parent project. */
|
||||
projectId: string;
|
||||
/** UUID of the backing project workspace, when present. */
|
||||
projectWorkspaceId: string | null;
|
||||
/** Absolute filesystem path to the workspace when locally realized. */
|
||||
path: string | null;
|
||||
/** Current working directory for local workspace tooling. */
|
||||
cwd: string | null;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Base ref configured for the workspace, when known. */
|
||||
baseRef: string | null;
|
||||
/** Branch name configured for the workspace, when known. */
|
||||
branchName: string | null;
|
||||
/** Host provider type for the realized workspace. */
|
||||
providerType: string | null;
|
||||
/** Provider metadata already safe for plugin consumption. */
|
||||
providerMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host API surfaces exposed via PluginContext
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -818,6 +871,19 @@ export interface PluginProjectsClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.executionWorkspaces` — read execution workspace metadata.
|
||||
*
|
||||
* Requires `execution.workspaces.read`.
|
||||
*/
|
||||
export interface PluginExecutionWorkspacesClient {
|
||||
/**
|
||||
* Return plugin-safe metadata for an execution workspace. The host enforces
|
||||
* company access before returning any workspace coordinates.
|
||||
*/
|
||||
get(workspaceId: string, companyId: string): Promise<PluginExecutionWorkspaceMetadata | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
|
||||
*
|
||||
@@ -892,9 +958,12 @@ export interface PluginActionsClient {
|
||||
* Register a handler for a plugin-defined action key.
|
||||
*
|
||||
* @param key - Stable string identifier for this action (e.g. `"resync"`)
|
||||
* @param handler - Async function that receives action params and returns a result
|
||||
* @param handler - Async function that receives action params plus immutable host actor context and returns a result
|
||||
*/
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void;
|
||||
register(
|
||||
key: string,
|
||||
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1523,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1642,6 +1874,9 @@ export interface PluginContext {
|
||||
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
|
||||
projects: PluginProjectsClient;
|
||||
|
||||
/** Read execution workspace metadata. Requires `execution.workspaces.read`. */
|
||||
executionWorkspaces: PluginExecutionWorkspacesClient;
|
||||
|
||||
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
|
||||
routines: PluginRoutinesClient;
|
||||
|
||||
@@ -1660,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,
|
||||
|
||||
@@ -54,6 +54,7 @@ export type {
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
@@ -229,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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
@@ -76,6 +78,8 @@ import type {
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
@@ -85,6 +89,7 @@ import type {
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginInvocationContext,
|
||||
WorkerToHostMethodName,
|
||||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
@@ -279,13 +284,17 @@ 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[] = [];
|
||||
const jobHandlers = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launcherRegistrations = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<
|
||||
string,
|
||||
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
||||
>();
|
||||
const toolHandlers = new Map<string, {
|
||||
declaration: Pick<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">;
|
||||
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>;
|
||||
@@ -365,7 +374,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 +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
|
||||
}
|
||||
@@ -657,6 +674,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
},
|
||||
|
||||
executionWorkspaces: {
|
||||
async get(workspaceId: string, companyId: string) {
|
||||
return callHost("executionWorkspaces.get", { workspaceId, companyId });
|
||||
},
|
||||
},
|
||||
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey: string, companyId: string) {
|
||||
@@ -1080,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);
|
||||
@@ -1087,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
|
||||
actions: {
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
|
||||
register(
|
||||
key: string,
|
||||
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
||||
): void {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
@@ -1169,7 +1274,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);
|
||||
@@ -1407,11 +1515,36 @@ 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 {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
|
||||
return value === "user" || value === "agent" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext {
|
||||
const rawActor = params.actorContext && typeof params.actorContext === "object"
|
||||
? params.actorContext
|
||||
: null;
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(rawActor?.type),
|
||||
userId: stringOrNull(rawActor?.userId),
|
||||
agentId: stringOrNull(rawActor?.agentId),
|
||||
runId: stringOrNull(rawActor?.runId),
|
||||
companyId: stringOrNull(rawActor?.companyId),
|
||||
});
|
||||
return Object.freeze({
|
||||
actor,
|
||||
companyId: actor.companyId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
|
||||
@@ -1420,9 +1553,12 @@ 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1591,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)}`,
|
||||
|
||||
Reference in New Issue
Block a user