diff --git a/plugin-agent-permissions/src/constants.ts b/plugin-agent-permissions/src/constants.ts index 4a7f5e9..067297a 100644 --- a/plugin-agent-permissions/src/constants.ts +++ b/plugin-agent-permissions/src/constants.ts @@ -1,23 +1,18 @@ // Shared permission keys for agent permissions plugin +// Only the single real enforced permission is exposed here. export const PERMISSION_KEYS = [ - "agents:create", - "users:invite", - "users:manage_permissions", - "tasks:assign", - "tasks:assign_scope", - "joins:approve" + "canCreateAgents", ] as const; export type PermissionKey = typeof PERMISSION_KEYS[number]; export const PERMISSION_LABELS: Record = { - "agents:create": "Create Agents", - "users:invite": "Invite Users", - "users:manage_permissions": "Manage Permissions", - "tasks:assign": "Assign Tasks", - "tasks:assign_scope": "Task Assignment Scope", - "joins:approve": "Approve Join Requests" + "canCreateAgents": "Can Hire Agents", +}; + +export const PERMISSION_DESCRIPTIONS: Record = { + "canCreateAgents": "Allows this agent to create (hire) new agents", }; // Pagination constants diff --git a/plugin-agent-permissions/src/manifest.ts b/plugin-agent-permissions/src/manifest.ts index d2fe3b1..b728134 100644 --- a/plugin-agent-permissions/src/manifest.ts +++ b/plugin-agent-permissions/src/manifest.ts @@ -10,8 +10,7 @@ const manifest: PaperclipPluginManifestV1 = { categories: ["ui", "automation"], capabilities: [ "agents.read", - "plugin.state.read", - "plugin.state.write", + "agents.update-permissions", "ui.detailTab.register", "ui.page.register", "instance.settings.register" @@ -27,7 +26,7 @@ const manifest: PaperclipPluginManifestV1 = { id: "permissions-page", displayName: "Permissions", exportName: "PermissionsPage", - routePath: "/permissions" + routePath: "permissions" }, { type: "settingsPage", diff --git a/plugin-agent-permissions/src/ui/AgentPermissionsTab.tsx b/plugin-agent-permissions/src/ui/AgentPermissionsTab.tsx index 2b0db8b..8b9d89c 100644 --- a/plugin-agent-permissions/src/ui/AgentPermissionsTab.tsx +++ b/plugin-agent-permissions/src/ui/AgentPermissionsTab.tsx @@ -1,18 +1,24 @@ import { useState } from "react"; import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui"; import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; -import { PERMISSION_KEYS, PERMISSION_LABELS, type PermissionKey } from "../constants"; +import { PERMISSION_LABELS, PERMISSION_DESCRIPTIONS } from "../constants"; + +interface AgentPermissionsData { + agentId: string; + agentName: string; + canCreateAgents: boolean; +} export function AgentPermissionsTab({ context }: PluginDetailTabProps) { - const { entityId: agentId } = context; - - const { data, loading, error, refresh } = usePluginData<{ - agentId: string; - permissions: Record; - }>("agent-permissions", { agentId }); - + const { entityId: agentId, companyId } = context; + + const { data, loading, error, refresh } = usePluginData( + "agent-permissions", + { agentId, companyId } + ); + const togglePermission = usePluginAction("toggle-agent-permission"); - const [updating, setUpdating] = useState(null); + const [updating, setUpdating] = useState(false); const [lastError, setLastError] = useState(null); if (loading) return
Loading permissions...
; @@ -23,18 +29,18 @@ export function AgentPermissionsTab({ context }: PluginDetailTabProps) { ); if (!data) return
No permissions data available
; - async function handleToggle(permissionKey: PermissionKey, currentEnabled: boolean) { + async function handleToggle(currentEnabled: boolean) { setLastError(null); - setUpdating(permissionKey); + setUpdating(true); try { - await togglePermission({ agentId, permissionKey, enabled: !currentEnabled }); + await togglePermission({ agentId, companyId, enabled: !currentEnabled }); await refresh(); } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - setLastError(error); - console.error("Failed to toggle permission:", error); + const e = err instanceof Error ? err : new Error(String(err)); + setLastError(e); + console.error("Failed to toggle permission:", e); } finally { - setUpdating(null); + setUpdating(false); } } @@ -44,13 +50,13 @@ export function AgentPermissionsTab({ context }: PluginDetailTabProps) {

Control what actions this agent can perform.

- + {lastError && ( -
Failed to update: {lastError.message}
)} - +
Permission toggles
- {PERMISSION_KEYS.map((key) => { - const enabled = data.permissions[key] ?? false; - const isUpdating = updating === key; - - return ( - - ); - })} + updating... + + )} +
diff --git a/plugin-agent-permissions/src/ui/PermissionsNav.tsx b/plugin-agent-permissions/src/ui/PermissionsNav.tsx new file mode 100644 index 0000000..7197daf --- /dev/null +++ b/plugin-agent-permissions/src/ui/PermissionsNav.tsx @@ -0,0 +1,70 @@ +import { usePluginData, useHostContext } from "@paperclipai/plugin-sdk/ui"; +import type { PluginSidebarProps } from "@paperclipai/plugin-sdk/ui"; +import { SIDEBAR_PREVIEW_LIMIT, type PermissionKey } from "../constants"; + +interface AgentPermissionsSummary { + agentId: string; + agentName: string; + permissions: Record; +} + +export function PermissionsNav(_props: PluginSidebarProps) { + const { companyId } = useHostContext(); + const { data: agentsData, loading, error } = usePluginData( + "all-agents-permissions", + companyId ? { companyId } : undefined + ); + + if (loading) return ( +
+ Loading permissions... +
+ ); + if (error) return ( +
+ Error: {error.message} +
+ ); + if (!agentsData || agentsData.length === 0) { + return ( +
+

Permissions

+

No agents found

+
+ ); + } + + const agentsWithPermissions = agentsData.filter(a => + Object.values(a.permissions).some(v => v) + ); + + return ( +
+

Permissions

+

+ {agentsWithPermissions.length} agent(s) with custom permissions +

+
    + {agentsData.slice(0, SIDEBAR_PREVIEW_LIMIT).map(agent => { + const permCount = Object.values(agent.permissions).filter(Boolean).length; + return ( +
  • +
    {agent.agentName}
    +
    + {permCount} permission(s) granted +
    +
  • + ); + })} +
+
+ ); +} diff --git a/plugin-agent-permissions/src/ui/PermissionsPage.tsx b/plugin-agent-permissions/src/ui/PermissionsPage.tsx index 15cee55..f9d09aa 100644 --- a/plugin-agent-permissions/src/ui/PermissionsPage.tsx +++ b/plugin-agent-permissions/src/ui/PermissionsPage.tsx @@ -1,32 +1,32 @@ import { useState } from "react"; import { usePluginData, usePluginAction, useHostContext } from "@paperclipai/plugin-sdk/ui"; import type { PluginPageProps } from "@paperclipai/plugin-sdk/ui"; -import { PERMISSION_KEYS, PERMISSION_LABELS, type PermissionKey } from "../constants"; +import { PERMISSION_LABELS, PERMISSION_DESCRIPTIONS } from "../constants"; interface AgentPermissionsSummary { agentId: string; agentName: string; - permissions: Record; + canCreateAgents: boolean; } export function PermissionsPage(_props: PluginPageProps) { const { companyId } = useHostContext(); - + const { data: agentsData, loading, error, refresh } = usePluginData( "all-agents-permissions", companyId ? { companyId, limit: 100 } : {} ); - + const togglePermission = usePluginAction("toggle-agent-permission"); - const [updating, setUpdating] = useState<{ agentId: string; permissionKey: PermissionKey } | null>(null); + const [updating, setUpdating] = useState(null); const [lastError, setLastError] = useState(null); const [expandedAgents, setExpandedAgents] = useState>(new Set()); - async function handleToggle(agentId: string, permissionKey: PermissionKey, currentEnabled: boolean) { + async function handleToggle(agentId: string, currentEnabled: boolean) { setLastError(null); - setUpdating({ agentId, permissionKey }); + setUpdating(agentId); try { - await togglePermission({ agentId, permissionKey, enabled: !currentEnabled }); + await togglePermission({ agentId, companyId, enabled: !currentEnabled }); await refresh(); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -86,11 +86,11 @@ export function PermissionsPage(_props: PluginPageProps) { {lastError && ( -
{agentsData.map(agent => { - const permCount = Object.values(agent.permissions).filter(Boolean).length; const isExpanded = expandedAgents.has(agent.agentId); - + const isUpdating = updating === agent.agentId; + return ( -
- {agent.agentName}
- {permCount > 0 && ( - - {permCount} permission(s) + can hire )} - + {isExpanded && (
Permission toggles for {agent.agentName}
- {PERMISSION_KEYS.map((key) => { - const enabled = agent.permissions[key] ?? false; - const isUpdating = updating?.agentId === agent.agentId && updating?.permissionKey === key; - - return ( - - ); - })} +
@@ -222,15 +209,3 @@ export function PermissionsPage(_props: PluginPageProps) {
); } - -function getPermissionDescription(key: PermissionKey): string { - const descriptions: Record = { - "agents:create": "Allows this agent to create new agents (agent hiring)", - "users:invite": "Allows inviting board users to the company", - "users:manage_permissions": "Allows managing user permissions", - "tasks:assign": "Allows assigning tasks to agents", - "tasks:assign_scope": "Scope for task assignment (limits which agents can be assigned)", - "joins:approve": "Allows approving join requests" - }; - return descriptions[key]; -} diff --git a/plugin-agent-permissions/src/ui/PermissionsSettingsPage.tsx b/plugin-agent-permissions/src/ui/PermissionsSettingsPage.tsx index 5b980f0..c4757f0 100644 --- a/plugin-agent-permissions/src/ui/PermissionsSettingsPage.tsx +++ b/plugin-agent-permissions/src/ui/PermissionsSettingsPage.tsx @@ -1,17 +1,13 @@ import { useState } from "react"; -import { useHostContext, usePluginData, usePluginAction, usePluginToast, type PluginSettingsPageProps } from "@paperclipai/plugin-sdk/ui"; +import { usePluginData, usePluginAction, usePluginToast } from "@paperclipai/plugin-sdk/ui"; +import type { PluginSettingsPageProps } from "@paperclipai/plugin-sdk/ui"; +import { PERMISSION_LABELS, PERMISSION_DESCRIPTIONS } from "../constants"; -type Permission = { - capability: string; - allowed: boolean; -}; - -type AgentRecord = { - id: string; - name: string; - status: string; - description?: string | null; -}; +interface AgentPermissionsSummary { + agentId: string; + agentName: string; + canCreateAgents: boolean; +} const layoutStyle = { display: "grid", @@ -23,7 +19,7 @@ const cardStyle = { borderRadius: "12px", overflow: "hidden", background: "var(--card, transparent)", -};; +}; const headerStyle = { display: "flex", @@ -32,14 +28,13 @@ const headerStyle = { padding: "14px 16px", cursor: "pointer", background: "color-mix(in srgb, var(--muted, #888) 6%, transparent)", - userSelect: "none", }; const contentStyle = { padding: "14px 16px 16px", display: "grid", gap: "12px", -};; +}; const permissionRowStyle = { display: "flex", @@ -49,13 +44,13 @@ const permissionRowStyle = { padding: "8px 10px", borderRadius: "8px", border: "1px solid color-mix(in srgb, var(--border) 60%, transparent)", -};; +}; const mutedTextStyle = { fontSize: "12px", opacity: 0.72, lineHeight: 1.45, -};; +}; function ChevronIcon({ expanded }: { expanded: boolean }) { return ( @@ -67,6 +62,7 @@ function ChevronIcon({ expanded }: { expanded: boolean }) { style={{ transition: "transform 0.15s ease", transform: expanded ? "rotate(180deg)" : "rotate(0deg)", + flexShrink: 0, }} > @@ -74,40 +70,19 @@ function ChevronIcon({ expanded }: { expanded: boolean }) { ); } -function StatusBadge({ status }: { status: string }) { - const isActive = status === "active"; - return ( - - {status} - - ); -} - export function PermissionsSettingsPage({ context }: PluginSettingsPageProps) { const companyId = context.companyId; - - const agentsQuery = usePluginData("agents", companyId ? { companyId } : {}); - const permissionsQuery = usePluginData("agent-permissions", companyId ? { companyId } : {}); - const updatePermissions = usePluginAction("update-agent-permissions"); + + const { data: agentsData, loading, error, refresh } = usePluginData( + "all-agents-permissions", + companyId ? { companyId, limit: 100 } : {} + ); + + const togglePermission = usePluginAction("toggle-agent-permission"); const toast = usePluginToast(); - + const [expandedAgents, setExpandedAgents] = useState>(new Set()); - - const agents = agentsQuery.data ?? []; - const allPermissions = permissionsQuery.data ?? []; - - function getAgentPermissions(agentId: string): Permission[] { - return allPermissions.filter((p) => p.capability.startsWith(`${agentId}:`)); - } + const [updating, setUpdating] = useState(null); function handleToggleAgent(agentId: string) { setExpandedAgents((prev) => { @@ -121,29 +96,25 @@ export function PermissionsSettingsPage({ context }: PluginSettingsPageProps) { }); } - async function handleTogglePermission(agentId: string, capability: string, newValue: boolean) { - if (!companyId) return; - + async function handleToggle(agentId: string, currentEnabled: boolean) { + setUpdating(agentId); try { - await updatePermissions({ - companyId, - agentId, - capability, - allowed: newValue, - }); - permissionsQuery.refresh(); + await togglePermission({ agentId, companyId, enabled: !currentEnabled }); + await refresh(); toast({ title: "Permission updated", - body: `${capability}: ${newValue ? "allowed" : "denied"}`, + body: `${PERMISSION_LABELS["canCreateAgents"]}: ${!currentEnabled ? "allowed" : "denied"}`, tone: "success", }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); toast({ title: "Failed to update permission", body: message, tone: "error", }); + } finally { + setUpdating(null); } } @@ -157,7 +128,7 @@ export function PermissionsSettingsPage({ context }: PluginSettingsPageProps) { ); } - if (agentsQuery.loading || permissionsQuery.loading) { + if (loading) { return (
@@ -167,7 +138,17 @@ export function PermissionsSettingsPage({ context }: PluginSettingsPageProps) { ); } - if (agents.length === 0) { + if (error) { + return ( +
+
+ Failed to load: {error.message} +
+
+ ); + } + + if (!agentsData || agentsData.length === 0) { return (
@@ -180,74 +161,57 @@ export function PermissionsSettingsPage({ context }: PluginSettingsPageProps) { return (
- Agent Permissions
- Manage permissions for each agent in your company. Toggle capabilities to allow or deny specific actions. + Toggle hiring permission to allow or deny each agent from creating new agents.
- {agents.map((agent) => { - const isExpanded = expandedAgents.has(agent.id); - const agentPermissions = getAgentPermissions(agent.id); + {agentsData.map((agent) => { + const isExpanded = expandedAgents.has(agent.agentId); + const isUpdating = updating === agent.agentId; return ( -
-
handleToggleAgent(agent.id)}> +
+
handleToggleAgent(agent.agentId)}>
-
- {agent.name} - -
- {agent.description ? ( -
{agent.description}
- ) : null} + {agent.agentName}
- - {agentPermissions.length} permissions - + {agent.canCreateAgents && ( + + can hire + + )}
{isExpanded ? (
- {agentPermissions.length === 0 ? ( -
- No custom permissions configured for this agent. +
+
+
+
{PERMISSION_LABELS["canCreateAgents"]}
+
{PERMISSION_DESCRIPTIONS["canCreateAgents"]}
+
+
- ) : ( -
- {agentPermissions.map((permission) => { - const capabilityName = permission.capability.replace(`${agent.id}:`, ""); - - return ( -
-
-
{capabilityName}
-
- {permission.capability} -
-
- -
- ); - })} -
- )} +
) : null}
diff --git a/plugin-agent-permissions/src/worker.ts b/plugin-agent-permissions/src/worker.ts index 25427e1..6d1943a 100644 --- a/plugin-agent-permissions/src/worker.ts +++ b/plugin-agent-permissions/src/worker.ts @@ -1,77 +1,36 @@ import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; -import { - PERMISSION_KEYS, - type PermissionKey, - DEFAULT_PAGE_LIMIT, - MAX_PAGE_LIMIT -} from "./constants"; +import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from "./constants"; -interface AgentPermissions { - agentId: string; - permissions: Record; -} - -interface AllAgentsPermissions { +interface AgentPermissionsSummary { agentId: string; agentName: string; - permissions: Record; + canCreateAgents: boolean; } -type AgentPermissionsMap = Record>; - const plugin = definePlugin({ async setup(ctx) { + // Per-agent data for the detail tab ctx.data.register("agent-permissions", async (params) => { - const { agentId } = params as { agentId: string }; - - const allPerms = (await ctx.state.get( - { scopeKind: "instance", stateKey: "agent_permissions" } - ) as AgentPermissionsMap) ?? {}; - - const agentPerms = allPerms[agentId] ?? {}; - + const agentId = typeof params.agentId === "string" ? params.agentId : ""; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + + if (!agentId) return null; + + // companyId may be empty when called from the detail tab; we still try + const agent = companyId + ? await ctx.agents.get(agentId, companyId) + : null; + + if (!agent) return null; + return { - agentId, - permissions: PERMISSION_KEYS.reduce((acc, key) => ({ - ...acc, - [key]: agentPerms[key] ?? false - }), {} as Record) + agentId: agent.id, + agentName: agent.name, + canCreateAgents: agent.permissions?.canCreateAgents ?? false, }; }); - ctx.actions.register("toggle-agent-permission", async (params) => { - const { agentId, permissionKey, enabled } = params as { - agentId: string; - permissionKey: PermissionKey; - enabled: boolean; - }; - - // Input validation - if (!agentId || typeof agentId !== 'string') { - throw new Error('Invalid agentId: must be a non-empty string'); - } - if (!PERMISSION_KEYS.includes(permissionKey)) { - throw new Error(`Invalid permission key: ${permissionKey}`); - } - if (typeof enabled !== 'boolean') { - throw new Error('Invalid enabled: must be a boolean'); - } - - const allPerms = (await ctx.state.get( - { scopeKind: "instance", stateKey: "agent_permissions" } - ) as AgentPermissionsMap) ?? {}; - - const agentPerms = allPerms[agentId] ?? {}; - const updated = { ...agentPerms, [permissionKey]: enabled }; - - await ctx.state.set( - { scopeKind: "instance", stateKey: "agent_permissions" }, - { ...allPerms, [agentId]: updated } - ); - - return { success: true }; - }); - + // All-agents list for the permissions page and settings page ctx.data.register("all-agents-permissions", async (params) => { const companyId = typeof params.companyId === "string" ? params.companyId : ""; const limit = Math.min( @@ -79,26 +38,43 @@ const plugin = definePlugin({ MAX_PAGE_LIMIT ); const offset = Math.max(0, Number(params.offset) || 0); - - const agents = companyId + + const agents = companyId ? await ctx.agents.list({ companyId, limit, offset }) : []; - - const allPerms = (await ctx.state.get( - { scopeKind: "instance", stateKey: "agent_permissions" } - ) as AgentPermissionsMap) ?? {}; - - const result: AllAgentsPermissions[] = agents.map(agent => ({ + + const result: AgentPermissionsSummary[] = agents.map(agent => ({ agentId: agent.id, agentName: agent.name, - permissions: PERMISSION_KEYS.reduce((acc, key) => ({ - ...acc, - [key]: allPerms[agent.id]?.[key] ?? false - }), {} as Record) + canCreateAgents: agent.permissions?.canCreateAgents ?? false, })); - + return result; }); + + ctx.actions.register("toggle-agent-permission", async (params) => { + const { agentId, companyId, enabled } = params as { + agentId: string; + companyId: string; + enabled: boolean; + }; + + if (!agentId || typeof agentId !== "string") { + throw new Error("Invalid agentId: must be a non-empty string"); + } + if (!companyId || typeof companyId !== "string") { + throw new Error("Invalid companyId: must be a non-empty string"); + } + if (typeof enabled !== "boolean") { + throw new Error("Invalid enabled: must be a boolean"); + } + + await ctx.agents.updatePermissions(agentId, companyId, { + canCreateAgents: enabled, + }); + + return { success: true }; + }); }, async onHealth() { diff --git a/testing.ts b/testing.ts new file mode 100644 index 0000000..327af71 --- /dev/null +++ b/testing.ts @@ -0,0 +1,745 @@ +import { randomUUID } from "node:crypto"; +import type { + PaperclipPluginManifestV1, + PluginCapability, + PluginEventType, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; +import type { + EventFilter, + PluginContext, + PluginEntityRecord, + PluginEntityUpsert, + PluginJobContext, + PluginLauncherRegistration, + PluginEvent, + ScopeKey, + ToolResult, + ToolRunContext, + PluginWorkspace, + AgentSession, + AgentSessionEvent, +} from "./types.js"; + +export interface TestHarnessOptions { + /** Plugin manifest used to seed capability checks and metadata. */ + manifest: PaperclipPluginManifestV1; + /** Optional capability override. Defaults to `manifest.capabilities`. */ + capabilities?: PluginCapability[]; + /** Initial config returned by `ctx.config.get()`. */ + config?: Record; +} + +export interface TestHarnessLogEntry { + level: "info" | "warn" | "error" | "debug"; + message: string; + meta?: Record; +} + +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(input: { + companies?: Company[]; + projects?: Project[]; + issues?: Issue[]; + issueComments?: IssueComment[]; + agents?: Agent[]; + goals?: Goal[]; + }): void; + setConfig(config: Record): void; + /** Dispatch a host or plugin event to registered handlers. */ + emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial): Promise; + /** Execute a previously-registered scheduled job handler. */ + runJob(jobKey: string, partial?: Partial): Promise; + /** Invoke a `ctx.data.register(...)` handler by key. */ + getData(key: string, params?: Record): Promise; + /** Invoke a `ctx.actions.register(...)` handler by key. */ + performAction(key: string, params?: Record): Promise; + /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ + executeTool(name: string, params: unknown, runCtx?: Partial): Promise; + /** Read raw in-memory state for assertions. */ + getState(input: ScopeKey): unknown; + /** Simulate a streaming event arriving for an active session. */ + simulateSessionEvent(sessionId: string, event: Omit): void; + logs: TestHarnessLogEntry[]; + activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>; + metrics: Array<{ name: string; value: number; tags?: Record }>; +} + +type EventRegistration = { + name: PluginEventType | `plugin.${string}`; + filter?: EventFilter; + fn: (event: PluginEvent) => Promise; +}; + +function normalizeScope(input: ScopeKey): Required> & Pick { + return { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace ?? "default", + stateKey: input.stateKey, + }; +} + +function stateMapKey(input: ScopeKey): string { + const normalized = normalizeScope(input); + return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`; +} + +function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean { + if (!filter) return true; + if (filter.companyId && filter.companyId !== String((event.payload as Record | undefined)?.companyId ?? "")) return false; + if (filter.projectId && filter.projectId !== String((event.payload as Record | undefined)?.projectId ?? "")) return false; + if (filter.agentId && filter.agentId !== String((event.payload as Record | undefined)?.agentId ?? "")) return false; + return true; +} + +function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set, capability: PluginCapability) { + if (allowed.has(capability)) return; + throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`); +} + +function requireCompanyId(companyId?: string): string { + if (!companyId) throw new Error("companyId is required for this operation"); + return companyId; +} + +function isInCompany( + record: T | null | undefined, + companyId: string, +): record is T { + return Boolean(record && record.companyId === companyId); +} + +/** + * Create an in-memory host harness for plugin worker tests. + * + * The harness enforces declared capabilities and simulates host APIs, so tests + * can validate plugin behavior without spinning up the Paperclip server runtime. + */ +export function createTestHarness(options: TestHarnessOptions): TestHarness { + const manifest = options.manifest; + const capabilitySet = new Set(options.capabilities ?? manifest.capabilities); + let currentConfig = { ...(options.config ?? {}) }; + + const logs: TestHarnessLogEntry[] = []; + const activity: TestHarness["activity"] = []; + const metrics: TestHarness["metrics"] = []; + + const state = new Map(); + const entities = new Map(); + const entityExternalIndex = new Map(); + const companies = new Map(); + const projects = new Map(); + const issues = new Map(); + const issueComments = new Map(); + const agents = new Map(); + const goals = new Map(); + const projectWorkspaces = new Map(); + + const sessions = new Map(); + const sessionEventCallbacks = new Map void>(); + + const events: EventRegistration[] = []; + const jobs = new Map Promise>(); + const launchers = new Map(); + const dataHandlers = new Map) => Promise>(); + const actionHandlers = new Map) => Promise>(); + const toolHandlers = new Map Promise>(); + + const ctx: PluginContext = { + manifest, + config: { + async get() { + return { ...currentConfig }; + }, + }, + events: { + on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise): () => void { + requireCapability(manifest, capabilitySet, "events.subscribe"); + let registration: EventRegistration; + if (typeof filterOrFn === "function") { + registration = { name, fn: filterOrFn }; + } else { + if (!maybeFn) throw new Error("event handler is required"); + registration = { name, filter: filterOrFn, fn: maybeFn }; + } + events.push(registration); + return () => { + const idx = events.indexOf(registration); + if (idx !== -1) events.splice(idx, 1); + }; + }, + async emit(name, companyId, payload) { + requireCapability(manifest, capabilitySet, "events.emit"); + await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId }); + }, + }, + jobs: { + register(key, fn) { + requireCapability(manifest, capabilitySet, "jobs.schedule"); + jobs.set(key, fn); + }, + }, + launchers: { + register(launcher) { + launchers.set(launcher.id, launcher); + }, + }, + http: { + async fetch(url, init) { + requireCapability(manifest, capabilitySet, "http.outbound"); + return fetch(url, init); + }, + }, + secrets: { + async resolve(secretRef) { + requireCapability(manifest, capabilitySet, "secrets.read-ref"); + return `resolved:${secretRef}`; + }, + }, + activity: { + async log(entry) { + requireCapability(manifest, capabilitySet, "activity.log.write"); + activity.push(entry); + }, + }, + state: { + async get(input) { + requireCapability(manifest, capabilitySet, "plugin.state.read"); + return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null; + }, + async set(input, value) { + requireCapability(manifest, capabilitySet, "plugin.state.write"); + state.set(stateMapKey(input), value); + }, + async delete(input) { + requireCapability(manifest, capabilitySet, "plugin.state.write"); + state.delete(stateMapKey(input)); + }, + }, + entities: { + async upsert(input: PluginEntityUpsert) { + const externalKey = input.externalId + ? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}` + : null; + const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined; + const existing = existingId ? entities.get(existingId) : undefined; + const now = new Date().toISOString(); + const previousExternalKey = existing?.externalId + ? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}` + : null; + const record: PluginEntityRecord = existing + ? { + ...existing, + entityType: input.entityType, + scopeKind: input.scopeKind, + scopeId: input.scopeId ?? null, + externalId: input.externalId ?? null, + title: input.title ?? null, + status: input.status ?? null, + data: input.data, + updatedAt: now, + } + : { + id: randomUUID(), + entityType: input.entityType, + scopeKind: input.scopeKind, + scopeId: input.scopeId ?? null, + externalId: input.externalId ?? null, + title: input.title ?? null, + status: input.status ?? null, + data: input.data, + createdAt: now, + updatedAt: now, + }; + entities.set(record.id, record); + if (previousExternalKey && previousExternalKey !== externalKey) { + entityExternalIndex.delete(previousExternalKey); + } + if (externalKey) entityExternalIndex.set(externalKey, record.id); + return record; + }, + async list(query) { + let out = [...entities.values()]; + if (query.entityType) out = out.filter((r) => r.entityType === query.entityType); + if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind); + if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId); + if (query.externalId) out = out.filter((r) => r.externalId === query.externalId); + if (query.offset) out = out.slice(query.offset); + if (query.limit) out = out.slice(0, query.limit); + return out; + }, + }, + projects: { + async list(input) { + requireCapability(manifest, capabilitySet, "projects.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...projects.values()]; + out = out.filter((project) => project.companyId === companyId); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(projectId, companyId) { + requireCapability(manifest, capabilitySet, "projects.read"); + const project = projects.get(projectId); + return isInCompany(project, companyId) ? project : null; + }, + async listWorkspaces(projectId, companyId) { + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + if (!isInCompany(projects.get(projectId), companyId)) return []; + return projectWorkspaces.get(projectId) ?? []; + }, + async getPrimaryWorkspace(projectId, companyId) { + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + if (!isInCompany(projects.get(projectId), companyId)) return null; + const workspaces = projectWorkspaces.get(projectId) ?? []; + return workspaces.find((workspace) => workspace.isPrimary) ?? null; + }, + async getWorkspaceForIssue(issueId, companyId) { + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + const issue = issues.get(issueId); + if (!isInCompany(issue, companyId)) return null; + const projectId = (issue as unknown as Record)?.projectId as string | undefined; + if (!projectId) return null; + if (!isInCompany(projects.get(projectId), companyId)) return null; + const workspaces = projectWorkspaces.get(projectId) ?? []; + return workspaces.find((workspace) => workspace.isPrimary) ?? null; + }, + }, + companies: { + async list(input) { + requireCapability(manifest, capabilitySet, "companies.read"); + let out = [...companies.values()]; + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(companyId) { + requireCapability(manifest, capabilitySet, "companies.read"); + return companies.get(companyId) ?? null; + }, + }, + issues: { + async list(input) { + requireCapability(manifest, capabilitySet, "issues.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...issues.values()]; + out = out.filter((issue) => issue.companyId === companyId); + if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId); + if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId); + if (input?.status) out = out.filter((issue) => issue.status === input.status); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issues.read"); + const issue = issues.get(issueId); + return isInCompany(issue, companyId) ? issue : null; + }, + async create(input) { + requireCapability(manifest, capabilitySet, "issues.create"); + const now = new Date(); + const record: Issue = { + id: randomUUID(), + companyId: input.companyId, + projectId: input.projectId ?? null, + goalId: input.goalId ?? null, + parentId: input.parentId ?? null, + title: input.title, + description: input.description ?? null, + status: "todo", + priority: input.priority ?? "medium", + assigneeAgentId: input.assigneeAgentId ?? null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: null, + identifier: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + }; + issues.set(record.id, record); + return record; + }, + async update(issueId, patch, companyId) { + requireCapability(manifest, capabilitySet, "issues.update"); + const record = issues.get(issueId); + if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); + const updated: Issue = { + ...record, + ...patch, + updatedAt: new Date(), + }; + issues.set(issueId, updated); + return updated; + }, + async listComments(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.comments.read"); + if (!isInCompany(issues.get(issueId), companyId)) return []; + return issueComments.get(issueId) ?? []; + }, + async createComment(issueId, body, companyId) { + requireCapability(manifest, capabilitySet, "issue.comments.create"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } + const now = new Date(); + const comment: IssueComment = { + id: randomUUID(), + companyId: parentIssue.companyId, + issueId, + authorAgentId: null, + authorUserId: null, + body, + createdAt: now, + updatedAt: now, + }; + const current = issueComments.get(issueId) ?? []; + current.push(comment); + issueComments.set(issueId, current); + return comment; + }, + documents: { + async list(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return []; + return []; + }, + async get(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return null; + return null; + }, + async upsert(input) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(input.issueId); + if (!isInCompany(parentIssue, input.companyId)) { + throw new Error(`Issue not found: ${input.issueId}`); + } + throw new Error("documents.upsert is not implemented in test context"); + }, + async delete(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } + }, + }, + }, + agents: { + async list(input) { + requireCapability(manifest, capabilitySet, "agents.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...agents.values()]; + out = out.filter((agent) => agent.companyId === companyId); + if (input?.status) out = out.filter((agent) => agent.status === input.status); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agents.read"); + const agent = agents.get(agentId); + return isInCompany(agent, companyId) ? agent : null; + }, + async pause(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agents.pause"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent"); + const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() }; + agents.set(agentId, updated); + return updated; + }, + async resume(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agents.resume"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent"); + if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed"); + const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() }; + agents.set(agentId, updated); + return updated; + }, + async invoke(agentId, companyId, opts) { + requireCapability(manifest, capabilitySet, "agents.invoke"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + if ( + agent!.status === "paused" || + agent!.status === "terminated" || + agent!.status === "pending_approval" + ) { + throw new Error(`Agent is not invokable in its current state: ${agent!.status}`); + } + return { runId: randomUUID() }; + }, + async updatePermissions(agentId, companyId, permissions) { + requireCapability(manifest, capabilitySet, "agents.update-permissions"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + const updated: Agent = { + ...agent!, + permissions: { ...agent!.permissions, ...permissions }, + updatedAt: new Date(), + }; + agents.set(agentId, updated); + return updated; + }, + sessions: { + async create(agentId, companyId, opts) { + requireCapability(manifest, capabilitySet, "agent.sessions.create"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + const session: AgentSession = { + sessionId: randomUUID(), + agentId, + companyId: cid, + status: "active", + createdAt: new Date().toISOString(), + }; + sessions.set(session.sessionId, session); + return session; + }, + async list(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agent.sessions.list"); + const cid = requireCompanyId(companyId); + return [...sessions.values()].filter( + (s) => s.agentId === agentId && s.companyId === cid && s.status === "active", + ); + }, + async sendMessage(sessionId, companyId, opts) { + requireCapability(manifest, capabilitySet, "agent.sessions.send"); + const session = sessions.get(sessionId); + if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`); + if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); + if (opts.onEvent) { + sessionEventCallbacks.set(sessionId, opts.onEvent); + } + return { runId: randomUUID() }; + }, + async close(sessionId, companyId) { + requireCapability(manifest, capabilitySet, "agent.sessions.close"); + const session = sessions.get(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); + session.status = "closed"; + sessionEventCallbacks.delete(sessionId); + }, + }, + }, + goals: { + async list(input) { + requireCapability(manifest, capabilitySet, "goals.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...goals.values()]; + out = out.filter((goal) => goal.companyId === companyId); + if (input?.level) out = out.filter((goal) => goal.level === input.level); + if (input?.status) out = out.filter((goal) => goal.status === input.status); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(goalId, companyId) { + requireCapability(manifest, capabilitySet, "goals.read"); + const goal = goals.get(goalId); + return isInCompany(goal, companyId) ? goal : null; + }, + async create(input) { + requireCapability(manifest, capabilitySet, "goals.create"); + const now = new Date(); + const record: Goal = { + id: randomUUID(), + companyId: input.companyId, + title: input.title, + description: input.description ?? null, + level: input.level ?? "task", + status: input.status ?? "planned", + parentId: input.parentId ?? null, + ownerAgentId: input.ownerAgentId ?? null, + createdAt: now, + updatedAt: now, + }; + goals.set(record.id, record); + return record; + }, + async update(goalId, patch, companyId) { + requireCapability(manifest, capabilitySet, "goals.update"); + const record = goals.get(goalId); + if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`); + const updated: Goal = { + ...record, + ...patch, + updatedAt: new Date(), + }; + goals.set(goalId, updated); + return updated; + }, + }, + data: { + register(key, handler) { + dataHandlers.set(key, handler); + }, + }, + actions: { + register(key, handler) { + actionHandlers.set(key, handler); + }, + }, + streams: (() => { + const channelCompanyMap = new Map(); + return { + open(channel: string, companyId: string) { + channelCompanyMap.set(channel, companyId); + }, + emit(_channel: string, _event: unknown) { + // No-op in test harness — events are not forwarded + }, + close(channel: string) { + channelCompanyMap.delete(channel); + }, + }; + })(), + tools: { + register(name, _decl, fn) { + requireCapability(manifest, capabilitySet, "agent.tools.register"); + toolHandlers.set(name, fn); + }, + }, + metrics: { + async write(name, value, tags) { + requireCapability(manifest, capabilitySet, "metrics.write"); + metrics.push({ name, value, tags }); + }, + }, + logger: { + info(message, meta) { + logs.push({ level: "info", message, meta }); + }, + warn(message, meta) { + logs.push({ level: "warn", message, meta }); + }, + error(message, meta) { + logs.push({ level: "error", message, meta }); + }, + debug(message, meta) { + logs.push({ level: "debug", message, meta }); + }, + }, + }; + + const harness: TestHarness = { + ctx, + seed(input) { + for (const row of input.companies ?? []) companies.set(row.id, row); + for (const row of input.projects ?? []) projects.set(row.id, row); + for (const row of input.issues ?? []) issues.set(row.id, row); + for (const row of input.issueComments ?? []) { + const list = issueComments.get(row.issueId) ?? []; + list.push(row); + issueComments.set(row.issueId, list); + } + for (const row of input.agents ?? []) agents.set(row.id, row); + for (const row of input.goals ?? []) goals.set(row.id, row); + }, + setConfig(config) { + currentConfig = { ...config }; + }, + async emit(eventType, payload, base) { + const event: PluginEvent = { + eventId: base?.eventId ?? randomUUID(), + eventType, + companyId: base?.companyId ?? "test-company", + occurredAt: base?.occurredAt ?? new Date().toISOString(), + actorId: base?.actorId, + actorType: base?.actorType, + entityId: base?.entityId, + entityType: base?.entityType, + payload, + }; + + for (const handler of events) { + const exactMatch = handler.name === event.eventType; + const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin."); + const wildcardPluginOne = String(handler.name).endsWith(".*") + && String(event.eventType).startsWith(String(handler.name).slice(0, -1)); + if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; + if (!allowsEvent(handler.filter, event)) continue; + await handler.fn(event); + } + }, + async runJob(jobKey, partial = {}) { + const handler = jobs.get(jobKey); + if (!handler) throw new Error(`No job handler registered for '${jobKey}'`); + await handler({ + jobKey, + runId: partial.runId ?? randomUUID(), + trigger: partial.trigger ?? "manual", + scheduledAt: partial.scheduledAt ?? new Date().toISOString(), + }); + }, + async getData(key: string, params: Record = {}) { + const handler = dataHandlers.get(key); + if (!handler) throw new Error(`No data handler registered for '${key}'`); + return await handler(params) as T; + }, + async performAction(key: string, params: Record = {}) { + const handler = actionHandlers.get(key); + if (!handler) throw new Error(`No action handler registered for '${key}'`); + return await handler(params) as T; + }, + async executeTool(name: string, params: unknown, runCtx: Partial = {}) { + const handler = toolHandlers.get(name); + if (!handler) throw new Error(`No tool handler registered for '${name}'`); + const ctxToPass: ToolRunContext = { + agentId: runCtx.agentId ?? "agent-test", + runId: runCtx.runId ?? randomUUID(), + companyId: runCtx.companyId ?? "company-test", + projectId: runCtx.projectId ?? "project-test", + }; + return await handler(params, ctxToPass) as T; + }, + getState(input) { + return state.get(stateMapKey(input)); + }, + simulateSessionEvent(sessionId, event) { + const cb = sessionEventCallbacks.get(sessionId); + if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`); + cb({ ...event, sessionId }); + }, + logs, + activity, + metrics, + }; + + return harness; +}